{"id":133990,"date":"2026-09-10T02:02:43","date_gmt":"2026-09-10T02:02:43","guid":{"rendered":"https:\/\/www.hostinger.com\/ph\/tutorials\/langgraph-tutorial\/"},"modified":"2026-09-10T02:02:43","modified_gmt":"2026-09-10T02:02:43","slug":"langgraph-tutorial","status":"publish","type":"post","link":"\/ph\/tutorials\/langgraph-tutorial\/","title":{"rendered":"LangGraph tutorial: How to build an AI agent in Python"},"content":{"rendered":"<p class=\"wp-block-paragraph\">To build an AI agent in Python with LangGraph, define its shared state and model node, connect them in a graph, then add tools, memory, streaming, and human approval.<\/p><p class=\"wp-block-paragraph\">You&rsquo;ll build the LangGraph agent in eight stages:<\/p><ol class=\"wp-block-list\">\n<li>Set up the project and install LangGraph.<\/li>\n\n\n\n<li>Define the shared state your nodes use.<\/li>\n\n\n\n<li>Create the model node that calls the AI.<\/li>\n\n\n\n<li>Connect and run your first graph.<\/li>\n\n\n\n<li>Add tools and decide when the agent should use them.<\/li>\n\n\n\n<li>Add memory across separate agent calls.<\/li>\n\n\n\n<li>Stream the agent&rsquo;s responses as they&rsquo;re generated.<\/li>\n\n\n\n<li>Pause tool calls for human approval.<\/li>\n<\/ol><p class=\"wp-block-paragraph\">After developing your LangGraph agent locally, you&rsquo;ll deploy it to a Linux virtual private server (VPS) so it keeps running even after you shut down your computer.<\/p><p class=\"wp-block-paragraph\"><\/p><h2 class=\"wp-block-heading\" id=\"h-what-is-langgraph\">What is LangGraph?<\/h2><p class=\"wp-block-paragraph\">LangGraph is a <strong>Python framework from LangChain for building stateful <\/strong><a href=\"\/ph\/tutorials\/what-are-ai-agents\/\">AI agents<\/a><strong> as multi-step graphs of nodes and edges<\/strong>. Stateful means the workflow keeps and updates information as it runs instead of treating every action as isolated.<\/p><p class=\"wp-block-paragraph\">It gives you direct control over what your agent does next. You can create workflows with branching, loops, tool use, memory, streaming, and human approval instead of forcing every request through the same fixed sequence.<\/p><p class=\"wp-block-paragraph\">In a LangGraph workflow, nodes perform tasks, edges control which step runs next, and shared state carries information between those steps. The graph can return to an earlier node, letting your agent repeat an action until it reaches a stopping condition.<\/p><p class=\"wp-block-paragraph\">For example, you can build a research assistant that moves between reasoning and information retrieval until it has enough evidence to answer.<\/p><p class=\"wp-block-paragraph\">This looping behavior is common in <a href=\"\/ph\/tutorials\/what-is-agentic-ai\/\">agentic AI<\/a>, where an agent chooses its next action based on the information it has gathered &ndash; the same pattern behind most <a href=\"\/ph\/tutorials\/ai-agent-examples\/\">AI agent examples<\/a> that need branching, repeated actions, or human review.&nbsp;<\/p><p class=\"wp-block-paragraph\">Use LangGraph when your agent needs to loop, branch, call tools, or pause for approval. If a single prompt and response solves your problem, call the model API directly.<\/p><h3 class=\"wp-block-heading\">What&rsquo;s the difference between LangGraph and LangChain?<\/h3><p class=\"wp-block-paragraph\">The difference between LangGraph and LangChain is the <strong>level of control you have over your agent&rsquo;s workflow<\/strong>.<\/p><p class=\"wp-block-paragraph\">LangChain provides higher-level APIs and integrations for common agent patterns, while LangGraph gives you direct control over state, routing, loops, and execution flow.<\/p><figure tabindex=\"0\" class=\"wp-block-table\"><table><tbody><tr><td><strong>Area<\/strong><\/td><td><strong>LangChain<\/strong><\/td><td><strong>LangGraph<\/strong><\/td><\/tr><tr><td>Abstraction level<\/td><td>Higher-level agent APIs and integrations<\/td><td>Lower-level control over workflow execution<\/td><\/tr><tr><td>State management<\/td><td>Provides built-in patterns for common agent state<\/td><td>Lets you define and update graph state directly<\/td><\/tr><\/tbody><\/table><\/figure><p class=\"wp-block-paragraph\">Use LangChain when its built-in agent patterns already fit your app. Use LangGraph when you need custom routing, persistent state across turns, repeated workflow steps, or human review.<\/p><p class=\"wp-block-paragraph\">You don&rsquo;t need LangChain&rsquo;s higher-level agent abstractions to use LangGraph. LangGraph works with LangChain components when you want ready-made model and tool integrations, but it doesn&rsquo;t require them.<\/p><p class=\"wp-block-paragraph\">The two aren&rsquo;t competitors &ndash; LangChain&rsquo;s own agent abstraction runs on LangGraph underneath, so you&rsquo;re choosing how much of the workflow to write yourself.<\/p><h2 class=\"wp-block-heading\" id=\"h-how-to-build-a-langgraph-agent-in-python\">How to build a LangGraph agent in Python<\/h2><p class=\"wp-block-paragraph\">To build a LangGraph agent in Python, <strong>define its state, create nodes that perform tasks, connect them with edges, add tools and conditional routing, and compile the graph into a runnable app<\/strong>.<\/p><p class=\"wp-block-paragraph\">By the end, you&rsquo;ll have one working LangGraph agent that remembers conversations, decides when to call tools, streams responses, and pauses selected actions for your approval.<\/p><h3 class=\"wp-block-heading\">1. Set up the project and install LangGraph<\/h3><p class=\"wp-block-paragraph\">To set up a LangGraph project, create the project folder, activate a Python virtual environment, and install LangGraph with <strong>pip<\/strong>.<\/p><p class=\"wp-block-paragraph\">You&rsquo;ll need Python 3.10 or later, an API key for an AI model, and the required Python packages. We&rsquo;ll use DeepSeek, but you can use another provider such as OpenAI or Anthropic by installing its corresponding LangChain package.<\/p><p class=\"wp-block-paragraph\">Open your terminal, create a folder named <strong>langgraph-agent<\/strong>, and move into it:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">mkdir langgraph-agent\ncd langgraph-agent<\/pre><p class=\"wp-block-paragraph\">Next, <a href=\"\/ph\/tutorials\/how-to-create-a-python-virtual-environment\/\">create a Python virtual environment<\/a> so this project&rsquo;s packages don&rsquo;t affect your other Python projects. On macOS or Linux, run:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">python3 -m venv .venv\nsource .venv\/bin\/activate\npython -m pip install --upgrade pip<\/pre><p class=\"wp-block-paragraph\">Your terminal should show <strong>(.venv)<\/strong> at the beginning of the prompt after activation.<\/p><p class=\"wp-block-paragraph\">Install LangGraph, the DeepSeek integration, and the other dependencies used in the project:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">pip install langgraph==1.2.11 langchain-deepseek==1.1.0 langchain-core==1.6.1 python-dotenv==1.2.3<\/pre><p class=\"wp-block-paragraph\">Pinning these versions keeps the project&rsquo;s direct dependencies consistent while you follow along.<\/p><p class=\"wp-block-paragraph\">Create the project files:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">touch main.py .env .gitignore requirements.txt<\/pre><p class=\"wp-block-paragraph\">Your <strong>langgraph-agent<\/strong> folder should now look like this:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"raw\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">langgraph-agent\/\n   .venv\/\n   main.py\n   .env\n   .gitignore\n   requirements.txt<\/pre><p class=\"wp-block-paragraph\">Keep the terminal open, then open <strong>langgraph-agent<\/strong> in your code editor. In VS Code, select <strong>File &rarr; Open Folder<\/strong>, then choose <strong>langgraph-agent<\/strong>.<\/p><div class=\"wp-block-image\"><figure data-wp-context='{\"imageId\":\"6aa215bd853b5\"}' data-wp-interactive=\"core\/image\" data-wp-key=\"6aa215bd853b5\" class=\"aligncenter size-large wp-lightbox-container\"><img decoding=\"async\" data-wp-class--hide=\"state.isContentHidden\" data-wp-class--show=\"state.isContentVisible\" data-wp-init=\"callbacks.setButtonStyles\" data-wp-on--click=\"actions.showLightbox\" data-wp-on--load=\"callbacks.setButtonStyles\" data-wp-on--pointerdown=\"actions.preloadImage\" data-wp-on--pointerenter=\"actions.preloadImageWithDelay\" data-wp-on--pointerleave=\"actions.cancelPreload\" data-wp-on-window--resize=\"callbacks.setButtonStyles\" src=\"https:\/\/imagedelivery.net\/LqiWLm-3MGbYHtFuUbcBtA\/wp-content\/uploads\/sites\/2\/2026\/09\/vscode-langgraph-agent-project-structure.png\/w=1024,h=1024,fit=scale-down\" alt=\"VS Code showing the langgraph-agent project files and empty main.py file\" class=\"wp-image-156595\" title=\"vscode-langgraph-agent-project-structure\"><button class=\"lightbox-trigger\" type=\"button\" aria-haspopup=\"dialog\" data-wp-bind--aria-label=\"state.thisImage.triggerButtonAriaLabel\" data-wp-init=\"callbacks.initTriggerButton\" data-wp-on--click=\"actions.showLightbox\" data-wp-style--right=\"state.thisImage.buttonRight\" data-wp-style--top=\"state.thisImage.buttonTop\">\n\t\t\t<svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"12\" height=\"12\" fill=\"none\" viewbox=\"0 0 12 12\">\n\t\t\t\t<path fill=\"#fff\" d=\"M2 0a2 2 0 0 0-2 2v2h1.5V2a.5.5 0 0 1 .5-.5h2V0H2Zm2 10.5H2a.5.5 0 0 1-.5-.5V8H0v2a2 2 0 0 0 2 2h2v-1.5ZM8 12v-1.5h2a.5.5 0 0 0 .5-.5V8H12v2a2 2 0 0 1-2 2H8Zm2-12a2 2 0 0 1 2 2v2h-1.5V2a.5.5 0 0 0-.5-.5H8V0h2Z\"><\/path>\n\t\t\t<\/svg>\n\t\t<\/button><\/figure><\/div><p class=\"wp-block-paragraph\">Open <strong>requirements.txt<\/strong> and add the same dependencies:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">langgraph==1.2.11\nlangchain-deepseek==1.1.0\nlangchain-core==1.6.1\npython-dotenv==1.2.3<\/pre><p class=\"wp-block-paragraph\">This file lets you recreate the environment later with <strong>pip install -r requirements.txt<\/strong>.<\/p><p class=\"wp-block-paragraph\">Next, open <strong>.env<\/strong> and add your DeepSeek API key:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">DEEPSEEK_API_KEY=your-deepseek-api-key<\/pre><p class=\"wp-block-paragraph\">Replace <strong>your-deepseek-api-key<\/strong> with the API key from your DeepSeek account. Don&rsquo;t wrap the key in quotation marks.<\/p><p class=\"wp-block-paragraph\">Open <strong>.gitignore<\/strong> and add:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">.venv\/\n.env\n__pycache__\/<\/pre><p class=\"wp-block-paragraph\">These entries keep your virtual environment, API credentials, and Python cache files out of Git.<\/p><p class=\"wp-block-paragraph\">Finally, verify that Python loads the installed packages. Open <strong>main.py<\/strong> and add:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">from dotenv import load_dotenv\nfrom langchain_deepseek import ChatDeepSeek\nfrom langgraph.graph import StateGraph\n\nload_dotenv()\n\nprint(\"LangGraph and DeepSeek imports OK\")<\/pre><p class=\"wp-block-paragraph\">Save <strong>main.py<\/strong>. Then, return to the terminal and run:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">python main.py<\/pre><p class=\"wp-block-paragraph\">Your LangGraph setup is ready after the terminal prints <strong>LangGraph and DeepSeek imports OK<\/strong> without an import error. Keep <strong>main.py<\/strong> open because you&rsquo;ll continue building the agent in the same file.<\/p><div class=\"wp-block-image\"><figure data-wp-context='{\"imageId\":\"6aa215bd89e3a\"}' data-wp-interactive=\"core\/image\" data-wp-key=\"6aa215bd89e3a\" class=\"aligncenter size-large wp-lightbox-container\"><img decoding=\"async\" data-wp-class--hide=\"state.isContentHidden\" data-wp-class--show=\"state.isContentVisible\" data-wp-init=\"callbacks.setButtonStyles\" data-wp-on--click=\"actions.showLightbox\" data-wp-on--load=\"callbacks.setButtonStyles\" data-wp-on--pointerdown=\"actions.preloadImage\" data-wp-on--pointerenter=\"actions.preloadImageWithDelay\" data-wp-on--pointerleave=\"actions.cancelPreload\" data-wp-on-window--resize=\"callbacks.setButtonStyles\" src=\"https:\/\/imagedelivery.net\/LqiWLm-3MGbYHtFuUbcBtA\/wp-content\/uploads\/sites\/2\/2026\/09\/local-terminal-langgraph-agent-import-check.png\/w=1024,h=1024,fit=scale-down\" alt=\"Terminal showing LangGraph and DeepSeek imports loaded successfully\" class=\"wp-image-156583\" title=\"local-terminal-langgraph-agent-import-check\"><button class=\"lightbox-trigger\" type=\"button\" aria-haspopup=\"dialog\" data-wp-bind--aria-label=\"state.thisImage.triggerButtonAriaLabel\" data-wp-init=\"callbacks.initTriggerButton\" data-wp-on--click=\"actions.showLightbox\" data-wp-style--right=\"state.thisImage.buttonRight\" data-wp-style--top=\"state.thisImage.buttonTop\">\n\t\t\t<svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"12\" height=\"12\" fill=\"none\" viewbox=\"0 0 12 12\">\n\t\t\t\t<path fill=\"#fff\" d=\"M2 0a2 2 0 0 0-2 2v2h1.5V2a.5.5 0 0 1 .5-.5h2V0H2Zm2 10.5H2a.5.5 0 0 1-.5-.5V8H0v2a2 2 0 0 0 2 2h2v-1.5ZM8 12v-1.5h2a.5.5 0 0 0 .5-.5V8H12v2a2 2 0 0 1-2 2H8Zm2-12a2 2 0 0 1 2 2v2h-1.5V2a.5.5 0 0 0-.5-.5H8V0h2Z\"><\/path>\n\t\t\t<\/svg>\n\t\t<\/button><\/figure><\/div><h3 class=\"wp-block-heading\">2. Define the agent state<\/h3><p class=\"wp-block-paragraph\">Define the agent state by writing a <strong>TypedDict<\/strong> class that lists the fields every node can read and update.<\/p><p class=\"wp-block-paragraph\">This gives your LangGraph workflow a shared place to store and update conversation history while it runs. For this project, you only need one field called <strong>messages<\/strong>.<\/p><p class=\"wp-block-paragraph\">In <strong>main.py<\/strong>, add these imports with the existing imports:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">from typing import Annotated\n\nfrom langchain_core.messages import AnyMessage\nfrom langgraph.graph.message import add_messages\nfrom typing_extensions import TypedDict<\/pre><p class=\"wp-block-paragraph\">Below the imports, add the <strong>AgentState<\/strong> schema:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">class AgentState(TypedDict):\n   messages: Annotated[list[AnyMessage], add_messages]<\/pre><p class=\"wp-block-paragraph\"><strong>TypedDict<\/strong> defines the fields available in your state schema. Here, <strong>AgentState<\/strong> contains a <strong>messages<\/strong> list that each node can read and update as the graph runs.<\/p><p class=\"wp-block-paragraph\"><strong>Annotated<\/strong> attaches the <strong>add_messages<\/strong> reducer to the list. A reducer controls how LangGraph combines a node&rsquo;s update with the existing value instead of replacing it outright.<\/p><p class=\"wp-block-paragraph\">Without <strong>add_messages<\/strong>, a new <strong>messages<\/strong> value would replace the existing list. With <strong>add_messages<\/strong>, LangGraph merges new messages into the conversation history and updates an existing message when both messages use the same ID.<\/p><p>        <div class=\"protip\">\n            <div class=\"protip__heading\">\n                <svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http:\/\/www.w3.org\/2000\/svg\">\n                    <path d=\"M1.49234 23.5024C1.23229 23.5024 0.972242 23.4024 0.782206 23.2123C0.562165 22.9923 0.452144 22.6822 0.502153 22.3722C0.562165 21.9221 1.14227 17.9113 3.00262 16.351C3.63274 15.8209 4.43289 15.5509 5.26305 15.5609C6.09321 15.5909 6.87335 15.9109 7.47347 16.4911C8.6937 17.6913 8.76371 19.6717 7.6435 20.9919C6.0832 22.8523 2.08245 23.4324 1.63237 23.4924C1.59236 23.4924 1.54235 23.4924 1.50234 23.4924L1.49234 23.5024ZM5.16303 17.5613C4.84297 17.5613 4.53291 17.6713 4.29287 17.8813C3.60274 18.4614 3.07264 19.9317 2.75258 21.242C4.06282 20.9219 5.5331 20.3918 6.11321 19.7017C6.55329 19.1716 6.54329 18.3814 6.0832 17.9213C5.85316 17.7013 5.5431 17.5713 5.20304 17.5613C5.19304 17.5613 5.17303 17.5613 5.16303 17.5613ZM11.7243 21.8821C11.4942 21.8821 11.2642 21.8021 11.0841 21.652C10.8541 21.462 10.7241 21.1819 10.7241 20.8819V15.9109L8.08358 13.2705H3.11264C2.81259 13.2705 2.53254 13.1404 2.3425 12.9104C2.15246 12.6803 2.07245 12.3803 2.12246 12.0902C2.19247 11.7102 2.84259 8.36953 4.70294 7.12929C6.33325 6.04909 8.96375 6.49918 10.244 6.80923C11.5442 4.96889 13.2546 3.4286 15.2349 2.33839C17.4553 1.11816 19.9858 0.518051 22.4963 0.498047C23.0464 0.498047 23.4865 0.948132 23.4865 1.49824C23.4865 5.0389 22.3763 9.97983 17.1753 13.7605C17.4853 15.0408 17.9354 17.6613 16.8552 19.2816C15.615 21.1419 12.2744 21.7921 11.8943 21.8621C11.8343 21.8721 11.7743 21.8821 11.7143 21.8821H11.7243ZM12.7245 16.181V19.6016C13.7146 19.2916 14.7948 18.7915 15.2049 18.1814C15.675 17.4812 15.605 16.091 15.385 14.9008C14.5248 15.3808 13.6346 15.8109 12.7245 16.181ZM9.66388 12.0302L11.9643 14.3307C13.1845 13.8306 14.3648 13.2204 15.485 12.5103C19.9358 9.51974 21.2361 5.60901 21.4561 2.53843C19.6157 2.67846 17.8254 3.20856 16.2051 4.09872C14.2847 5.14892 12.6544 6.68921 11.4942 8.54956C10.7841 9.65977 10.174 10.82 9.66388 12.0302ZM4.39289 11.2701H7.81353C8.1936 10.3599 8.63368 9.46974 9.11377 8.60957C7.92355 8.38953 6.51329 8.31952 5.81315 8.78961C5.19304 9.19968 4.70294 10.3099 4.39289 11.2701Z\" fill=\"#673DE6\"\/>\n                <\/svg>\n                <p class=\"protip__title\">\n                    Return updates instead of changing state directly                <\/p>\n            <\/div>\n            <p class=\"protip__content\"> Don't edit the incoming <strong>state<\/strong> object inside a node. Return only the fields that changed, like <strong>{\"messages\": [response]}<\/strong>, so LangGraph can merge the update into the graph state correctly.<\/p>\n                    <\/div>\n        \n\n\n\n<\/p><p class=\"wp-block-paragraph\">For now, LangGraph doesn&rsquo;t save this state across separate graph runs. You&rsquo;ll add a checkpointer later so the agent can retain conversation history between runs.<\/p><h3 class=\"wp-block-heading\">3. Create the model node<\/h3><p class=\"wp-block-paragraph\">Create the model node as a Python function that reads your agent&rsquo;s messages, sends them to DeepSeek, and returns the model&rsquo;s response as a state update. You&rsquo;ll later register this function as a node in the LangGraph workflow.<\/p><p class=\"wp-block-paragraph\">In <strong>main.py<\/strong>, below the <strong>AgentState<\/strong> definition, initialize the model:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">model = ChatDeepSeek(\n   model=\"deepseek-v4-flash\",\n   extra_body={\n      \"thinking\": {\n         \"type\": \"disabled\"\n      }\n   },\n)<\/pre><p><div class=\"announcement-block announcement-block--important\">\n            <span class=\"announcement-block__heading\">\n                <svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http:\/\/www.w3.org\/2000\/svg\">\n                    <path fill-rule=\"evenodd\" clip-rule=\"evenodd\"\n                          d=\"M12 22.5C17.799 22.5 22.5 17.799 22.5 12C22.5 6.20101 17.799 1.5 12 1.5C6.20101 1.5 1.5 6.20101 1.5 12C1.5 17.799 6.20101 22.5 12 22.5ZM13.637 7.65198C13.637 6.74791 12.9041 6.01501 12 6.01501C11.0959 6.01501 10.363 6.74791 10.363 7.65198C10.5335 9.53749 10.875 13.383 10.875 13.383C10.875 14.0043 11.3787 14.508 12 14.508C12.6213 14.508 13.125 14.0043 13.125 13.383V13.38L13.637 7.65198ZM11.9927 15.714C11.3714 15.714 10.8677 16.2177 10.8677 16.839C10.8677 17.4603 11.3714 17.964 11.9927 17.964H12.0073C12.6286 17.964 13.1323 17.4603 13.1323 16.839C13.1323 16.2177 12.6286 15.714 12.0073 15.714H11.9927Z\"\n                          fill=\"#FEA419\"\/>\n                <\/svg>\n                Important\n            <\/span>\n            <p class=\"announcement-block__content\">\n                <strong>Important!<\/strong> DeepSeek V4 Flash uses thinking mode by default. We disable it here because DeepSeek requires <strong>reasoning_content<\/strong> from tool-calling responses to be passed back in subsequent requests. A <a href=\"https:\/\/github.com\/langchain-ai\/langchain\/issues\/39370\" target=\"_blank\" rel=\"noopener\">LangChain GitHub issue<\/a> reports that <strong>langchain-deepseek==1.1.0<\/strong> drops this field in multi-turn tool calls, causing DeepSeek to return a <strong>400<\/strong> error.\n            <\/p><\/div>\n\n\n\n<\/p><p class=\"wp-block-paragraph\">Below the model initialization, add <strong>model_node<\/strong>:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">def model_node(state: AgentState):\n   response = model.invoke(state[\"messages\"])\n   return {\"messages\": [response]}<\/pre><p class=\"wp-block-paragraph\">The function receives the full <strong>AgentState<\/strong> but returns only the field it changes. It reads <strong>messages<\/strong>, sends the conversation to DeepSeek, and returns the new AI message under the same key.<\/p><p class=\"wp-block-paragraph\">Add <strong>HumanMessage<\/strong> with the existing imports:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">from langchain_core.messages import HumanMessage<\/pre><p class=\"wp-block-paragraph\">Then, below <strong>model_node<\/strong>, add this temporary test:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">test_state = {\n   \"messages\": [\n      HumanMessage(content=\"Reply with exactly OK.\")\n   ]\n}\n\nresult = model_node(test_state)\nprint(result[\"messages\"][-1].content)<\/pre><p class=\"wp-block-paragraph\">You should see <strong>OK<\/strong> in the terminal. You now have a working model node that sends conversation state to DeepSeek and returns direct responses.<\/p><div class=\"wp-block-image\"><figure data-wp-context='{\"imageId\":\"6aa215bd8dff0\"}' data-wp-interactive=\"core\/image\" data-wp-key=\"6aa215bd8dff0\" class=\"aligncenter size-large wp-lightbox-container\"><img decoding=\"async\" data-wp-class--hide=\"state.isContentHidden\" data-wp-class--show=\"state.isContentVisible\" data-wp-init=\"callbacks.setButtonStyles\" data-wp-on--click=\"actions.showLightbox\" data-wp-on--load=\"callbacks.setButtonStyles\" data-wp-on--pointerdown=\"actions.preloadImage\" data-wp-on--pointerenter=\"actions.preloadImageWithDelay\" data-wp-on--pointerleave=\"actions.cancelPreload\" data-wp-on-window--resize=\"callbacks.setButtonStyles\" src=\"https:\/\/imagedelivery.net\/LqiWLm-3MGbYHtFuUbcBtA\/wp-content\/uploads\/sites\/2\/2026\/09\/local-terminal-langgraph-agent-model-node-test.png\/w=1024,h=1024,fit=scale-down\" alt=\"Terminal showing the DeepSeek model node test returning OK\" class=\"wp-image-156584\" title=\"local-terminal-langgraph-agent-model-node-test\"><button class=\"lightbox-trigger\" type=\"button\" aria-haspopup=\"dialog\" data-wp-bind--aria-label=\"state.thisImage.triggerButtonAriaLabel\" data-wp-init=\"callbacks.initTriggerButton\" data-wp-on--click=\"actions.showLightbox\" data-wp-style--right=\"state.thisImage.buttonRight\" data-wp-style--top=\"state.thisImage.buttonTop\">\n\t\t\t<svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"12\" height=\"12\" fill=\"none\" viewbox=\"0 0 12 12\">\n\t\t\t\t<path fill=\"#fff\" d=\"M2 0a2 2 0 0 0-2 2v2h1.5V2a.5.5 0 0 1 .5-.5h2V0H2Zm2 10.5H2a.5.5 0 0 1-.5-.5V8H0v2a2 2 0 0 0 2 2h2v-1.5ZM8 12v-1.5h2a.5.5 0 0 0 .5-.5V8H12v2a2 2 0 0 1-2 2H8Zm2-12a2 2 0 0 1 2 2v2h-1.5V2a.5.5 0 0 0-.5-.5H8V0h2Z\"><\/path>\n\t\t\t<\/svg>\n\t\t<\/button><\/figure><\/div><h3 class=\"wp-block-heading\">4. Connect and run the first graph<\/h3><p class=\"wp-block-paragraph\">To connect and run your first graph in LangGraph, add <strong>model_node<\/strong> to <strong>StateGraph<\/strong>, connect it between <strong>START<\/strong> and <strong>END<\/strong>, then compile and invoke the graph.<\/p><p class=\"wp-block-paragraph\">Update the existing LangGraph import in <strong>main.py<\/strong> to include <strong>START<\/strong> and <strong>END<\/strong>:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">from langgraph.graph import END, START, StateGraph<\/pre><p class=\"wp-block-paragraph\"><strong>START<\/strong> marks where execution enters the graph, while <strong>END<\/strong> marks where it stops.<\/p><p class=\"wp-block-paragraph\">Below the temporary <strong>model-node<\/strong> test, create the graph builder with your <strong>AgentState<\/strong> schema. Then, register <strong>model_node<\/strong> as the <strong>model<\/strong> node:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">builder = StateGraph(AgentState)\nbuilder.add_node(\"model\", model_node)<\/pre><p class=\"wp-block-paragraph\">The <strong>builder<\/strong> stores the nodes and edges that define your workflow before you compile it.<\/p><p class=\"wp-block-paragraph\">Connect <strong>START<\/strong> to <strong>model<\/strong>, then connect <strong>model<\/strong> to <strong>END<\/strong>:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">builder.add_edge(START, \"model\")\nbuilder.add_edge(\"model\", END)<\/pre><p class=\"wp-block-paragraph\">These edges create the path <strong>START &rarr; model &rarr; END<\/strong>. Every request follows it because you haven&rsquo;t added conditional routing yet.<\/p><p class=\"wp-block-paragraph\">Then, compile the graph so you can run it:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">graph = builder.compile()<\/pre><p class=\"wp-block-paragraph\"><strong>compile()<\/strong> turns the <strong>StateGraph<\/strong> builder into an executable graph that you can run with methods such as <strong>invoke()<\/strong>.<\/p><p class=\"wp-block-paragraph\">Below <strong>graph = builder.compile()<\/strong>, add this graph test with <strong>invoke()<\/strong>, then print DeepSeek&rsquo;s response. Remove the temporary <strong>test_state<\/strong> code and the <strong>HumanMessage<\/strong> import because you no longer need them.<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">result = graph.invoke(\n   {\n      \"messages\": [\n         {\n            \"role\": \"user\",\n            \"content\": \"Give me one benefit of VPS hosting.\"\n         }\n      ]\n   }\n)\n\nprint(result[\"messages\"][-1].content)<\/pre><p class=\"wp-block-paragraph\">LangGraph sends the user message to the <strong>model<\/strong> node, adds DeepSeek&rsquo;s reply to the <strong>messages<\/strong> state, and then reaches <strong>END<\/strong>. The <strong>result<\/strong> variable contains the final state, including the original user message and DeepSeek&rsquo;s response.<\/p><div class=\"wp-block-image\"><figure data-wp-context='{\"imageId\":\"6aa215bd91b5b\"}' data-wp-interactive=\"core\/image\" data-wp-key=\"6aa215bd91b5b\" class=\"aligncenter size-large wp-lightbox-container\"><img decoding=\"async\" data-wp-class--hide=\"state.isContentHidden\" data-wp-class--show=\"state.isContentVisible\" data-wp-init=\"callbacks.setButtonStyles\" data-wp-on--click=\"actions.showLightbox\" data-wp-on--load=\"callbacks.setButtonStyles\" data-wp-on--pointerdown=\"actions.preloadImage\" data-wp-on--pointerenter=\"actions.preloadImageWithDelay\" data-wp-on--pointerleave=\"actions.cancelPreload\" data-wp-on-window--resize=\"callbacks.setButtonStyles\" src=\"https:\/\/imagedelivery.net\/LqiWLm-3MGbYHtFuUbcBtA\/wp-content\/uploads\/sites\/2\/2026\/09\/local-terminal-langgraph-agent-first-graph-response.png\/w=1024,h=1024,fit=scale-down\" alt=\"Terminal showing the first LangGraph graph returning a VPS hosting benefit\" class=\"wp-image-156582\" title=\"local-terminal-langgraph-agent-first-graph-response\"><button class=\"lightbox-trigger\" type=\"button\" aria-haspopup=\"dialog\" data-wp-bind--aria-label=\"state.thisImage.triggerButtonAriaLabel\" data-wp-init=\"callbacks.initTriggerButton\" data-wp-on--click=\"actions.showLightbox\" data-wp-style--right=\"state.thisImage.buttonRight\" data-wp-style--top=\"state.thisImage.buttonTop\">\n\t\t\t<svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"12\" height=\"12\" fill=\"none\" viewbox=\"0 0 12 12\">\n\t\t\t\t<path fill=\"#fff\" d=\"M2 0a2 2 0 0 0-2 2v2h1.5V2a.5.5 0 0 1 .5-.5h2V0H2Zm2 10.5H2a.5.5 0 0 1-.5-.5V8H0v2a2 2 0 0 0 2 2h2v-1.5ZM8 12v-1.5h2a.5.5 0 0 0 .5-.5V8H12v2a2 2 0 0 1-2 2H8Zm2-12a2 2 0 0 1 2 2v2h-1.5V2a.5.5 0 0 0-.5-.5H8V0h2Z\"><\/path>\n\t\t\t<\/svg>\n\t\t<\/button><\/figure><\/div><h3 class=\"wp-block-heading\">5. Add tools and conditional routing<\/h3><p class=\"wp-block-paragraph\">Add tools and conditional routing to LangGraph by defining a Python tool, binding it to DeepSeek, and routing the graph based on the model&rsquo;s response. For this agent, you&rsquo;ll add a shipping calculator and run it only for shipping-price questions.<\/p><p class=\"wp-block-paragraph\">In <strong>main.py<\/strong>, add <strong>tool<\/strong> with the existing imports:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">from langchain_core.tools import tool<\/pre><p class=\"wp-block-paragraph\">Below the model initialization and above <strong>model_node<\/strong>, define the shipping calculator:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">@tool\ndef calculate_shipping(weight_kg: float, zone: str) -&gt; str:\n   \"\"\"Calculate this demo store's shipping price in USD.\n   Use this tool for every shipping-price request.\n   Supported zones are US and EU.\n   \"\"\"\n   if weight_kg &lt;= 0:\n      return \"Weight must be greater than 0 kg.\"\n\n   zone = zone.upper()\n\n   base_rates = {\n      \"US\": 5.00,\n      \"EU\": 8.00,\n   }\n\n   per_kg_rates = {\n      \"US\": 1.25,\n      \"EU\": 1.75,\n   }\n\n   if zone not in base_rates:\n      return \"Supported zones are US and EU.\"\n\n   total = base_rates[zone] + per_kg_rates[zone] * weight_kg\n   return f\"${total:.2f}\"<\/pre><p class=\"wp-block-paragraph\">The <strong>@tool<\/strong> decorator turns the Python function into a tool that the model can request. Its name, description, and typed arguments tell DeepSeek what the tool does and what input values it expects.<\/p><p class=\"wp-block-paragraph\">Immediately below <strong>calculate_shipping<\/strong>, create the tool list and bind it to the existing model:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">tools = [calculate_shipping]\nmodel_with_tools = model.bind_tools(tools)<\/pre><p class=\"wp-block-paragraph\"><strong>bind_tools()<\/strong> makes the tool definition available to DeepSeek but doesn&rsquo;t execute the Python function. DeepSeek adds a request to the AI message&rsquo;s <strong>tool_calls<\/strong> field after it decides to use the calculator.<\/p><p class=\"wp-block-paragraph\">Replace the existing <strong>model_node<\/strong> function with this tool-enabled version:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">def model_node(state: AgentState):\n   response = model_with_tools.invoke(state[\"messages\"])\n   return {\"messages\": [response]}<\/pre><p class=\"wp-block-paragraph\">Add <strong>ToolNode<\/strong> and <strong>tools_condition<\/strong> with the existing imports in <strong>main.py<\/strong>:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">from langgraph.prebuilt import ToolNode, tools_condition<\/pre><p class=\"wp-block-paragraph\">Replace the current graph-building code, from <strong>builder = StateGraph(AgentState)<\/strong> to <strong>graph = builder.compile()<\/strong>, with:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">builder = StateGraph(AgentState)\n\nbuilder.add_node(\"model\", model_node)\nbuilder.add_node(\"tools\", ToolNode(tools))\n\nbuilder.add_edge(START, \"model\")\n\nbuilder.add_conditional_edges(\n   \"model\",\n   tools_condition,\n   {\n      \"tools\": \"tools\",\n      \"__end__\": END,\n   },\n)\n\nbuilder.add_edge(\"tools\", \"model\")\n\ngraph = builder.compile()<\/pre><p class=\"wp-block-paragraph\"><strong>tools_condition<\/strong> checks the latest AI message after <strong>model<\/strong> runs. It routes execution to <strong>tools<\/strong> after DeepSeek requests a tool and routes it to <strong>END<\/strong> after DeepSeek returns a final response without tool calls.<\/p><p>        <div class=\"protip\">\n            <div class=\"protip__heading\">\n                <svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http:\/\/www.w3.org\/2000\/svg\">\n                    <path d=\"M1.49234 23.5024C1.23229 23.5024 0.972242 23.4024 0.782206 23.2123C0.562165 22.9923 0.452144 22.6822 0.502153 22.3722C0.562165 21.9221 1.14227 17.9113 3.00262 16.351C3.63274 15.8209 4.43289 15.5509 5.26305 15.5609C6.09321 15.5909 6.87335 15.9109 7.47347 16.4911C8.6937 17.6913 8.76371 19.6717 7.6435 20.9919C6.0832 22.8523 2.08245 23.4324 1.63237 23.4924C1.59236 23.4924 1.54235 23.4924 1.50234 23.4924L1.49234 23.5024ZM5.16303 17.5613C4.84297 17.5613 4.53291 17.6713 4.29287 17.8813C3.60274 18.4614 3.07264 19.9317 2.75258 21.242C4.06282 20.9219 5.5331 20.3918 6.11321 19.7017C6.55329 19.1716 6.54329 18.3814 6.0832 17.9213C5.85316 17.7013 5.5431 17.5713 5.20304 17.5613C5.19304 17.5613 5.17303 17.5613 5.16303 17.5613ZM11.7243 21.8821C11.4942 21.8821 11.2642 21.8021 11.0841 21.652C10.8541 21.462 10.7241 21.1819 10.7241 20.8819V15.9109L8.08358 13.2705H3.11264C2.81259 13.2705 2.53254 13.1404 2.3425 12.9104C2.15246 12.6803 2.07245 12.3803 2.12246 12.0902C2.19247 11.7102 2.84259 8.36953 4.70294 7.12929C6.33325 6.04909 8.96375 6.49918 10.244 6.80923C11.5442 4.96889 13.2546 3.4286 15.2349 2.33839C17.4553 1.11816 19.9858 0.518051 22.4963 0.498047C23.0464 0.498047 23.4865 0.948132 23.4865 1.49824C23.4865 5.0389 22.3763 9.97983 17.1753 13.7605C17.4853 15.0408 17.9354 17.6613 16.8552 19.2816C15.615 21.1419 12.2744 21.7921 11.8943 21.8621C11.8343 21.8721 11.7743 21.8821 11.7143 21.8821H11.7243ZM12.7245 16.181V19.6016C13.7146 19.2916 14.7948 18.7915 15.2049 18.1814C15.675 17.4812 15.605 16.091 15.385 14.9008C14.5248 15.3808 13.6346 15.8109 12.7245 16.181ZM9.66388 12.0302L11.9643 14.3307C13.1845 13.8306 14.3648 13.2204 15.485 12.5103C19.9358 9.51974 21.2361 5.60901 21.4561 2.53843C19.6157 2.67846 17.8254 3.20856 16.2051 4.09872C14.2847 5.14892 12.6544 6.68921 11.4942 8.54956C10.7841 9.65977 10.174 10.82 9.66388 12.0302ZM4.39289 11.2701H7.81353C8.1936 10.3599 8.63368 9.46974 9.11377 8.60957C7.92355 8.38953 6.51329 8.31952 5.81315 8.78961C5.19304 9.19968 4.70294 10.3099 4.39289 11.2701Z\" fill=\"#673DE6\"\/>\n                <\/svg>\n                <p class=\"protip__title\">\n                    Use fixed routing rules when you can                <\/p>\n            <\/div>\n            <p class=\"protip__content\"> Use regular Python logic when your app already knows which path should run next. Let the model choose a path only when the decision depends on understanding the user's language or context.<\/p>\n                    <\/div>\n        \n\n\n\n<\/p><p class=\"wp-block-paragraph\"><strong>ToolNode<\/strong> executes the requested function and adds its result to the conversation state. The edge from <strong>tools<\/strong> back to <strong>model<\/strong> lets DeepSeek read the tool result and generate a final response.<\/p><p class=\"wp-block-paragraph\">Your graph now has two execution paths:<\/p><ul class=\"wp-block-list\">\n<li><strong>START &rarr; model &rarr; END<\/strong> for a direct response.<\/li>\n\n\n\n<li><strong>START &rarr; model &rarr; tools &rarr; model &rarr; END<\/strong> for a tool-assisted response.<\/li>\n<\/ul><p class=\"wp-block-paragraph\">The second path creates a cycle because the graph returns to <strong>model<\/strong> after the tool runs. This loop lets DeepSeek use the tool result before producing its final answer.<\/p><p>    <div class=\"announcement-block announcement-block--warning\">\n        <span class=\"announcement-block__heading\">\n            <svg width=\"24\" height=\"26\" viewBox=\"0 0 24 26\" fill=\"none\" xmlns=\"http:\/\/www.w3.org\/2000\/svg\">\n                <path fill-rule=\"evenodd\" clip-rule=\"evenodd\"\n                d=\"M12 23.5C17.799 23.5 22.5 18.799 22.5 13C22.5 7.20101 17.799 2.5 12 2.5C6.20101 2.5 1.5 7.20101 1.5 13C1.5 18.799 6.20101 23.5 12 23.5ZM13.637 8.65198C13.637 7.74791 12.9041 7.01501 12 7.01501C11.0959 7.01501 10.363 7.74791 10.363 8.65198C10.5335 10.5375 10.875 14.383 10.875 14.383C10.875 15.0043 11.3787 15.508 12 15.508C12.6213 15.508 13.125 15.0043 13.125 14.383V14.38L13.637 8.65198ZM11.9927 16.714C11.3714 16.714 10.8677 17.2177 10.8677 17.839C10.8677 18.4603 11.3714 18.964 11.9927 18.964H12.0073C12.6286 18.964 13.1323 18.4603 13.1323 17.839C13.1323 17.2177 12.6286 16.714 12.0073 16.714H11.9927Z\"\n                fill=\"#BE1025\"\/>\n            <\/svg>\n            Warning        <\/span>\n        <p class=\"announcement-block__content\">\n            <strong>Warning!<\/strong> Every loop needs a way to stop, such as a route to <strong>END<\/strong>. A graph that keeps looping without reaching an exit eventually hits LangGraph's recursion limit and raises <strong>GraphRecursionError<\/strong>.        <\/p>\n    <\/div>\n\n    \n\n\n\n<\/p><p class=\"wp-block-paragraph\">Replace the previous graph test with this direct-response test:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">result = graph.invoke(\n   {\n      \"messages\": [\n         {\n            \"role\": \"user\",\n            \"content\": \"What does a virtual private server do?\"\n         }\n      ]\n   }\n)\n\nprint(result[\"messages\"][-1].content)<\/pre><p class=\"wp-block-paragraph\">DeepSeek should answer directly because the prompt doesn&rsquo;t require the shipping calculator.<\/p><p class=\"wp-block-paragraph\">Add this second test immediately after the direct-response test to verify the tool path:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">result = graph.invoke(\n   {\n      \"messages\": [\n         {\n            \"role\": \"user\",\n            \"content\": \"What is the shipping price for 3 kg to EU?\"\n         }\n      ]\n   }\n)\n\nprint(result[\"messages\"][-1].content)<\/pre><p class=\"wp-block-paragraph\">DeepSeek should request <strong>calculate_shipping<\/strong>, which sends execution through <strong>ToolNode<\/strong> and then back to <strong>model<\/strong>. The calculator returns <strong>$13.25<\/strong> based on <strong>$8.00 + 3 &times; $1.75<\/strong>.<\/p><div class=\"wp-block-image\"><figure data-wp-context='{\"imageId\":\"6aa215bd95c76\"}' data-wp-interactive=\"core\/image\" data-wp-key=\"6aa215bd95c76\" class=\"aligncenter size-large wp-lightbox-container\"><img decoding=\"async\" data-wp-class--hide=\"state.isContentHidden\" data-wp-class--show=\"state.isContentVisible\" data-wp-init=\"callbacks.setButtonStyles\" data-wp-on--click=\"actions.showLightbox\" data-wp-on--load=\"callbacks.setButtonStyles\" data-wp-on--pointerdown=\"actions.preloadImage\" data-wp-on--pointerenter=\"actions.preloadImageWithDelay\" data-wp-on--pointerleave=\"actions.cancelPreload\" data-wp-on-window--resize=\"callbacks.setButtonStyles\" src=\"https:\/\/imagedelivery.net\/LqiWLm-3MGbYHtFuUbcBtA\/wp-content\/uploads\/sites\/2\/2026\/09\/local-terminal-langgraph-agent-tool-routing-response.png\/w=1024,h=1024,fit=scale-down\" alt=\"Terminal showing direct and tool-assisted LangGraph responses, including .25 shipping\" class=\"wp-image-156589\" title=\"local-terminal-langgraph-agent-tool-routing-response\"><button class=\"lightbox-trigger\" type=\"button\" aria-haspopup=\"dialog\" data-wp-bind--aria-label=\"state.thisImage.triggerButtonAriaLabel\" data-wp-init=\"callbacks.initTriggerButton\" data-wp-on--click=\"actions.showLightbox\" data-wp-style--right=\"state.thisImage.buttonRight\" data-wp-style--top=\"state.thisImage.buttonTop\">\n\t\t\t<svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"12\" height=\"12\" fill=\"none\" viewbox=\"0 0 12 12\">\n\t\t\t\t<path fill=\"#fff\" d=\"M2 0a2 2 0 0 0-2 2v2h1.5V2a.5.5 0 0 1 .5-.5h2V0H2Zm2 10.5H2a.5.5 0 0 1-.5-.5V8H0v2a2 2 0 0 0 2 2h2v-1.5ZM8 12v-1.5h2a.5.5 0 0 0 .5-.5V8H12v2a2 2 0 0 1-2 2H8Zm2-12a2 2 0 0 1 2 2v2h-1.5V2a.5.5 0 0 0-.5-.5H8V0h2Z\"><\/path>\n\t\t\t<\/svg>\n\t\t<\/button><\/figure><\/div><h3 class=\"wp-block-heading\">6. Add memory and persistence<\/h3><p class=\"wp-block-paragraph\">To add memory and persistence to LangGraph, compile your graph with a checkpointer and assign each conversation a <strong>thread_id<\/strong>. <strong>InMemorySaver<\/strong> keeps each thread&rsquo;s checkpoints available across separate <strong>invoke()<\/strong> calls while Python is running.<\/p><p class=\"wp-block-paragraph\">Your current graph in <strong>main.py<\/strong> doesn&rsquo;t retain messages between separate invocations. To see the problem, replace both test blocks at the end of <strong>main.py<\/strong> with this temporary test:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">graph.invoke(\n   {\n      \"messages\": [\n         {\n            \"role\": \"user\",\n            \"content\": \"My name is Jack.\"\n         }\n      ]\n   }\n)\n\nresult = graph.invoke(\n   {\n      \"messages\": [\n         {\n            \"role\": \"user\",\n            \"content\": \"What is my name?\"\n         }\n      ]\n   }\n)\n\nprint(result[\"messages\"][-1].content)<\/pre><p class=\"wp-block-paragraph\">The second invocation only receives <strong>&ldquo;What is my name?&rdquo;<\/strong>, so DeepSeek doesn&rsquo;t have the earlier message that identifies the user as Jack.<\/p><p class=\"wp-block-paragraph\">Add <strong>InMemorySaver<\/strong> with the existing imports in <strong>main.py<\/strong>:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">from langgraph.checkpoint.memory import InMemorySaver<\/pre><p class=\"wp-block-paragraph\">Then find the current graph compilation line:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">graph = builder.compile()<\/pre><p class=\"wp-block-paragraph\">Replace it with:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">memory = InMemorySaver()\ngraph = builder.compile(checkpointer=memory)<\/pre><p class=\"wp-block-paragraph\">Next, replace the temporary test at the end of <strong>main.py<\/strong> with this multi-turn conversation:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">config = {\n   \"configurable\": {\n      \"thread_id\": \"conversation-1\"\n   }\n}\n\ngraph.invoke(\n   {\n      \"messages\": [\n         {\n            \"role\": \"user\",\n            \"content\": \"My name is Jack.\"\n         }\n      ]\n   },\n   config=config,\n)\n\nresult = graph.invoke(\n   {\n      \"messages\": [\n         {\n            \"role\": \"user\",\n            \"content\": \"What is my name?\"\n         }\n      ]\n   },\n   config=config,\n)\n\nprint(result[\"messages\"][-1].content)<\/pre><p class=\"wp-block-paragraph\">Both calls use <strong>conversation-1<\/strong>, so LangGraph loads the checkpoint history for that thread during the second invocation. DeepSeek now receives the earlier message and has the context needed to answer that the user&rsquo;s name is Jack.<\/p><p class=\"wp-block-paragraph\">Add this second test immediately after the first conversation to confirm that a different <strong>thread_id<\/strong> keeps its history separate:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">new_config = {\n   \"configurable\": {\n      \"thread_id\": \"conversation-2\"\n   }\n}\n\nresult = graph.invoke(\n   {\n      \"messages\": [\n         {\n            \"role\": \"user\",\n            \"content\": \"What is my name?\"\n         }\n      ]\n   },\n   config=new_config,\n)\n\nprint(result[\"messages\"][-1].content)<\/pre><p class=\"wp-block-paragraph\">DeepSeek doesn&rsquo;t receive Jack&rsquo;s message because <strong>conversation-2<\/strong> has separate checkpoint history.<\/p><div class=\"wp-block-image\"><figure data-wp-context='{\"imageId\":\"6aa215bd99617\"}' data-wp-interactive=\"core\/image\" data-wp-key=\"6aa215bd99617\" class=\"aligncenter size-large wp-lightbox-container\"><img decoding=\"async\" data-wp-class--hide=\"state.isContentHidden\" data-wp-class--show=\"state.isContentVisible\" data-wp-init=\"callbacks.setButtonStyles\" data-wp-on--click=\"actions.showLightbox\" data-wp-on--load=\"callbacks.setButtonStyles\" data-wp-on--pointerdown=\"actions.preloadImage\" data-wp-on--pointerenter=\"actions.preloadImageWithDelay\" data-wp-on--pointerleave=\"actions.cancelPreload\" data-wp-on-window--resize=\"callbacks.setButtonStyles\" src=\"https:\/\/imagedelivery.net\/LqiWLm-3MGbYHtFuUbcBtA\/wp-content\/uploads\/sites\/2\/2026\/09\/local-terminal-langgraph-agent-thread-memory.png\/w=1024,h=1024,fit=scale-down\" alt=\"Terminal showing one LangGraph thread remembering Jack and another thread not remembering\" class=\"wp-image-156587\" title=\"local-terminal-langgraph-agent-thread-memory\"><button class=\"lightbox-trigger\" type=\"button\" aria-haspopup=\"dialog\" data-wp-bind--aria-label=\"state.thisImage.triggerButtonAriaLabel\" data-wp-init=\"callbacks.initTriggerButton\" data-wp-on--click=\"actions.showLightbox\" data-wp-style--right=\"state.thisImage.buttonRight\" data-wp-style--top=\"state.thisImage.buttonTop\">\n\t\t\t<svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"12\" height=\"12\" fill=\"none\" viewbox=\"0 0 12 12\">\n\t\t\t\t<path fill=\"#fff\" d=\"M2 0a2 2 0 0 0-2 2v2h1.5V2a.5.5 0 0 1 .5-.5h2V0H2Zm2 10.5H2a.5.5 0 0 1-.5-.5V8H0v2a2 2 0 0 0 2 2h2v-1.5ZM8 12v-1.5h2a.5.5 0 0 0 .5-.5V8H12v2a2 2 0 0 1-2 2H8Zm2-12a2 2 0 0 1 2 2v2h-1.5V2a.5.5 0 0 0-.5-.5H8V0h2Z\"><\/path>\n\t\t\t<\/svg>\n\t\t<\/button><\/figure><\/div><h3 class=\"wp-block-heading\">7. Stream the agent&rsquo;s outputs<\/h3><p class=\"wp-block-paragraph\">Stream your LangGraph agent with <strong>graph.stream()<\/strong> to receive output while the graph is still running instead of waiting for the entire workflow to finish. You can stream each node&rsquo;s state updates or display DeepSeek&rsquo;s response token by token.<\/p><p class=\"wp-block-paragraph\">Keep the graph-building code in <strong>main.py<\/strong> unchanged. Replace the memory tests at the end of the file, starting with <strong>config = {&hellip;}<\/strong> and ending with the <strong>conversation-2<\/strong> test, with:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">stream_config = {\n   \"configurable\": {\n      \"thread_id\": \"stream-demo\"\n   }\n}\n\nfor chunk in graph.stream(\n   {\n      \"messages\": [\n         {\n            \"role\": \"user\",\n            \"content\": \"What is the shipping price for 5 kg to US?\"\n         }\n      ]\n   },\n   config=stream_config,\n   stream_mode=\"updates\",\n   version=\"v2\",\n):\n   for node_name, update in chunk[\"data\"].items():\n      print(node_name, update)<\/pre><p class=\"wp-block-paragraph\">In <strong>updates<\/strong> mode, the loop prints each node&rsquo;s state update after that node finishes. The intended path for this shipping request is <strong>model &rarr; tools &rarr; model<\/strong>, so you&rsquo;ll see how the graph progresses instead of receiving only its final state.<\/p><div class=\"wp-block-image\"><figure data-wp-context='{\"imageId\":\"6aa215bd9d292\"}' data-wp-interactive=\"core\/image\" data-wp-key=\"6aa215bd9d292\" class=\"aligncenter size-large wp-lightbox-container\"><img decoding=\"async\" data-wp-class--hide=\"state.isContentHidden\" data-wp-class--show=\"state.isContentVisible\" data-wp-init=\"callbacks.setButtonStyles\" data-wp-on--click=\"actions.showLightbox\" data-wp-on--load=\"callbacks.setButtonStyles\" data-wp-on--pointerdown=\"actions.preloadImage\" data-wp-on--pointerenter=\"actions.preloadImageWithDelay\" data-wp-on--pointerleave=\"actions.cancelPreload\" data-wp-on-window--resize=\"callbacks.setButtonStyles\" src=\"https:\/\/imagedelivery.net\/LqiWLm-3MGbYHtFuUbcBtA\/wp-content\/uploads\/sites\/2\/2026\/09\/local-terminal-langgraph-agent-stream-updates.png\/w=1024,h=1024,fit=scale-down\" alt=\"Terminal showing a LangGraph approval interrupt for the calculate_shipping tool\" class=\"wp-image-156586\" title=\"local-terminal-langgraph-agent-stream-updates\"><button class=\"lightbox-trigger\" type=\"button\" aria-haspopup=\"dialog\" data-wp-bind--aria-label=\"state.thisImage.triggerButtonAriaLabel\" data-wp-init=\"callbacks.initTriggerButton\" data-wp-on--click=\"actions.showLightbox\" data-wp-style--right=\"state.thisImage.buttonRight\" data-wp-style--top=\"state.thisImage.buttonTop\">\n\t\t\t<svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"12\" height=\"12\" fill=\"none\" viewbox=\"0 0 12 12\">\n\t\t\t\t<path fill=\"#fff\" d=\"M2 0a2 2 0 0 0-2 2v2h1.5V2a.5.5 0 0 1 .5-.5h2V0H2Zm2 10.5H2a.5.5 0 0 1-.5-.5V8H0v2a2 2 0 0 0 2 2h2v-1.5ZM8 12v-1.5h2a.5.5 0 0 0 .5-.5V8H12v2a2 2 0 0 1-2 2H8Zm2-12a2 2 0 0 1 2 2v2h-1.5V2a.5.5 0 0 0-.5-.5H8V0h2Z\"><\/path>\n\t\t\t<\/svg>\n\t\t<\/button><\/figure><\/div><p class=\"wp-block-paragraph\">This test uses a new <strong>thread_id<\/strong> so the conversation history from the memory examples doesn&rsquo;t affect the result.<\/p><p class=\"wp-block-paragraph\">To stream DeepSeek&rsquo;s response token by token, replace the <strong>stream-demo<\/strong> test at the end of <strong>main.py<\/strong> with:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">for chunk in graph.stream(\n   {\n      \"messages\": [\n         {\n            \"role\": \"user\",\n            \"content\": \"Explain LangGraph state in two sentences.\"\n         }\n      ]\n   },\n   config={\n      \"configurable\": {\n         \"thread_id\": \"token-stream\"\n      }\n   },\n   stream_mode=\"messages\",\n   version=\"v2\",\n):\n   token, _ = chunk[\"data\"]\n\n   if token.content:\n      print(token.content, end=\"\", flush=True)<\/pre><p class=\"wp-block-paragraph\">In <strong>messages<\/strong> mode, LangGraph streams the model&rsquo;s response token by token while DeepSeek generates it. Use this mode for a chat interface that should display the response progressively instead of waiting for the complete answer.<\/p><div class=\"wp-block-image\"><figure data-wp-context='{\"imageId\":\"6aa215bda2a10\"}' data-wp-interactive=\"core\/image\" data-wp-key=\"6aa215bda2a10\" class=\"aligncenter size-large wp-lightbox-container\"><img decoding=\"async\" data-wp-class--hide=\"state.isContentHidden\" data-wp-class--show=\"state.isContentVisible\" data-wp-init=\"callbacks.setButtonStyles\" data-wp-on--click=\"actions.showLightbox\" data-wp-on--load=\"callbacks.setButtonStyles\" data-wp-on--pointerdown=\"actions.preloadImage\" data-wp-on--pointerenter=\"actions.preloadImageWithDelay\" data-wp-on--pointerleave=\"actions.cancelPreload\" data-wp-on-window--resize=\"callbacks.setButtonStyles\" src=\"https:\/\/imagedelivery.net\/LqiWLm-3MGbYHtFuUbcBtA\/wp-content\/uploads\/sites\/2\/2026\/09\/local-terminal-langgraph-agent-token-streaming.png\/w=1024,h=1024,fit=scale-down\" alt=\"Terminal showing a streamed two-sentence explanation of LangGraph state\" class=\"wp-image-156588\" title=\"local-terminal-langgraph-agent-token-streaming\"><button class=\"lightbox-trigger\" type=\"button\" aria-haspopup=\"dialog\" data-wp-bind--aria-label=\"state.thisImage.triggerButtonAriaLabel\" data-wp-init=\"callbacks.initTriggerButton\" data-wp-on--click=\"actions.showLightbox\" data-wp-style--right=\"state.thisImage.buttonRight\" data-wp-style--top=\"state.thisImage.buttonTop\">\n\t\t\t<svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"12\" height=\"12\" fill=\"none\" viewbox=\"0 0 12 12\">\n\t\t\t\t<path fill=\"#fff\" d=\"M2 0a2 2 0 0 0-2 2v2h1.5V2a.5.5 0 0 1 .5-.5h2V0H2Zm2 10.5H2a.5.5 0 0 1-.5-.5V8H0v2a2 2 0 0 0 2 2h2v-1.5ZM8 12v-1.5h2a.5.5 0 0 0 .5-.5V8H12v2a2 2 0 0 1-2 2H8Zm2-12a2 2 0 0 1 2 2v2h-1.5V2a.5.5 0 0 0-.5-.5H8V0h2Z\"><\/path>\n\t\t\t<\/svg>\n\t\t<\/button><\/figure><\/div><h3 class=\"wp-block-heading\">8. Add human approval with interrupts<\/h3><p class=\"wp-block-paragraph\">To add human approval with interrupts in LangGraph, pause the graph before a tool runs, then approve or reject the request before execution continues. You&rsquo;ll use <strong>interrupt()<\/strong> to pause the workflow and <strong>Command(resume=&hellip;)<\/strong> to send your decision back.<\/p><p class=\"wp-block-paragraph\">Update the existing <strong>typing<\/strong> import in <strong>main.py<\/strong>:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">from typing import Annotated, Literal<\/pre><p class=\"wp-block-paragraph\">Then update the existing LangChain message import:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">from langchain_core.messages import AIMessage, AnyMessage, ToolMessage<\/pre><p class=\"wp-block-paragraph\">Add <strong>Command<\/strong> and <strong>interrupt<\/strong> with the other LangGraph imports:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">from langgraph.types import Command, interrupt<\/pre><p class=\"wp-block-paragraph\">Below <strong>model_node<\/strong>, add the approval node:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">def approval_node(\n   state: AgentState,\n) -&gt; Command[Literal[\"tools\", \"__end__\"]]:\n   tool_calls = state[\"messages\"][-1].tool_calls\n\n   approved = interrupt(\n      {\n         \"question\": \"Approve these tool calls?\",\n         \"tool_calls\": tool_calls,\n      }\n   )\n\n   if approved is True:\n      return Command(goto=\"tools\")\n\n   rejected_messages = [\n      ToolMessage(\n         content=\"Tool execution was rejected by the user.\",\n         tool_call_id=tool_call[\"id\"],\n      )\n      for tool_call in tool_calls\n   ]\n\n   rejected_messages.append(\n      AIMessage(\n         content=\"The tool call was rejected, so no action was taken.\"\n      )\n   )\n\n   return Command(\n      update={\"messages\": rejected_messages},\n      goto=END,\n   )<\/pre><p class=\"wp-block-paragraph\"><strong>interrupt()<\/strong> pauses the graph and returns the approval question and pending tool calls to your Python code.<\/p><p class=\"wp-block-paragraph\">You then resume it by passing <strong>Command(resume=True)<\/strong> back into the graph, which sends execution to tools, or <strong>Command(resume=False)<\/strong>, which follows the rejection path.<\/p><p class=\"wp-block-paragraph\">The rejection path adds a <strong>ToolMessage<\/strong> for each pending call without executing the tool. This keeps the conversation history valid before the graph ends.<\/p><p class=\"wp-block-paragraph\">Next, replace the current graph-building code, from <strong>builder = StateGraph(AgentState)<\/strong> to <strong>graph = builder.compile(checkpointer=memory)<\/strong>, with:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">builder = StateGraph(AgentState)\n\nbuilder.add_node(\"model\", model_node)\nbuilder.add_node(\"approval\", approval_node)\nbuilder.add_node(\"tools\", ToolNode(tools))\n\nbuilder.add_edge(START, \"model\")\n\nbuilder.add_conditional_edges(\n   \"model\",\n   tools_condition,\n   {\n      \"tools\": \"approval\",\n      \"__end__\": END,\n   },\n)\n\nbuilder.add_edge(\"tools\", \"model\")\n\ngraph = builder.compile(checkpointer=memory)<\/pre><p class=\"wp-block-paragraph\">Tool requests now pass through <strong>approval<\/strong> before they reach <strong>tools<\/strong>. Direct responses still follow <strong>START &rarr; model &rarr; END<\/strong>, while tool requests pause at <strong>START &rarr; model &rarr; approval<\/strong> until you send a decision.<\/p><p class=\"wp-block-paragraph\">Replace the streaming test at the end of <strong>main.py<\/strong> with:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">approval_config = {\n   \"configurable\": {\n      \"thread_id\": \"approval-demo\"\n   }\n}\n\npending = graph.invoke(\n   {\n      \"messages\": [\n         {\n            \"role\": \"user\",\n            \"content\": \"What is the shipping price for 3 kg to EU?\"\n         }\n      ]\n   },\n   config=approval_config,\n)\n\nprint(pending[\"__interrupt__\"][0].value)<\/pre><p class=\"wp-block-paragraph\">The graph stops inside <strong>approval_node<\/strong> before the shipping calculator runs. The value under <strong>pending[&ldquo;__interrupt__&rdquo;]<\/strong> contains the approval question and requested tool call.<\/p><div class=\"wp-block-image\"><figure data-wp-context='{\"imageId\":\"6aa215bda4fa9\"}' data-wp-interactive=\"core\/image\" data-wp-key=\"6aa215bda4fa9\" class=\"aligncenter size-large wp-lightbox-container\"><img decoding=\"async\" data-wp-class--hide=\"state.isContentHidden\" data-wp-class--show=\"state.isContentVisible\" data-wp-init=\"callbacks.setButtonStyles\" data-wp-on--click=\"actions.showLightbox\" data-wp-on--load=\"callbacks.setButtonStyles\" data-wp-on--pointerdown=\"actions.preloadImage\" data-wp-on--pointerenter=\"actions.preloadImageWithDelay\" data-wp-on--pointerleave=\"actions.cancelPreload\" data-wp-on-window--resize=\"callbacks.setButtonStyles\" src=\"https:\/\/imagedelivery.net\/LqiWLm-3MGbYHtFuUbcBtA\/wp-content\/uploads\/sites\/2\/2026\/09\/local-terminal-langgraph-agent-stream-updates.png\/w=1024,h=1024,fit=scale-down\" alt=\"Terminal showing a LangGraph approval interrupt for the calculate_shipping tool\" class=\"wp-image-156586\" title=\"local-terminal-langgraph-agent-approval-interrupt\"><button class=\"lightbox-trigger\" type=\"button\" aria-haspopup=\"dialog\" data-wp-bind--aria-label=\"state.thisImage.triggerButtonAriaLabel\" data-wp-init=\"callbacks.initTriggerButton\" data-wp-on--click=\"actions.showLightbox\" data-wp-style--right=\"state.thisImage.buttonRight\" data-wp-style--top=\"state.thisImage.buttonTop\">\n\t\t\t<svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"12\" height=\"12\" fill=\"none\" viewbox=\"0 0 12 12\">\n\t\t\t\t<path fill=\"#fff\" d=\"M2 0a2 2 0 0 0-2 2v2h1.5V2a.5.5 0 0 1 .5-.5h2V0H2Zm2 10.5H2a.5.5 0 0 1-.5-.5V8H0v2a2 2 0 0 0 2 2h2v-1.5ZM8 12v-1.5h2a.5.5 0 0 0 .5-.5V8H12v2a2 2 0 0 1-2 2H8Zm2-12a2 2 0 0 1 2 2v2h-1.5V2a.5.5 0 0 0-.5-.5H8V0h2Z\"><\/path>\n\t\t\t<\/svg>\n\t\t<\/button><\/figure><\/div><p class=\"wp-block-paragraph\">Add this immediately after the test to approve the request:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">approved_result = graph.invoke(\n   Command(resume=True),\n   config=approval_config,\n)\n\nprint(approved_result[\"messages\"][-1].content)<\/pre><p class=\"wp-block-paragraph\">Both calls use <strong>approval-demo<\/strong> because LangGraph needs the same <strong>thread_id<\/strong> to resume the paused run. Passing <strong>True<\/strong> sends execution to <strong>tools<\/strong>, where the calculator returns <strong>$13.25<\/strong> from <strong>$8.00 + 3 &times; $1.75<\/strong>.<\/p><p class=\"wp-block-paragraph\">Save <strong>main.py<\/strong>, go to the terminal, then run:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">python main.py<\/pre><p class=\"wp-block-paragraph\">You should see the pending tool call first, followed by DeepSeek&rsquo;s final response after approval.<\/p><div class=\"wp-block-image\"><figure data-wp-context='{\"imageId\":\"6aa215bda8a1b\"}' data-wp-interactive=\"core\/image\" data-wp-key=\"6aa215bda8a1b\" class=\"aligncenter size-large wp-lightbox-container\"><img decoding=\"async\" data-wp-class--hide=\"state.isContentHidden\" data-wp-class--show=\"state.isContentVisible\" data-wp-init=\"callbacks.setButtonStyles\" data-wp-on--click=\"actions.showLightbox\" data-wp-on--load=\"callbacks.setButtonStyles\" data-wp-on--pointerdown=\"actions.preloadImage\" data-wp-on--pointerenter=\"actions.preloadImageWithDelay\" data-wp-on--pointerleave=\"actions.cancelPreload\" data-wp-on-window--resize=\"callbacks.setButtonStyles\" src=\"https:\/\/imagedelivery.net\/LqiWLm-3MGbYHtFuUbcBtA\/wp-content\/uploads\/sites\/2\/2026\/09\/local-terminal-langgraph-agent-approved-tool-call.png\/w=1024,h=1024,fit=scale-down\" alt=\"Terminal showing an approved calculate_shipping tool call returning .25\" class=\"wp-image-156581\" title=\"local-terminal-langgraph-agent-approved-tool-call\"><button class=\"lightbox-trigger\" type=\"button\" aria-haspopup=\"dialog\" data-wp-bind--aria-label=\"state.thisImage.triggerButtonAriaLabel\" data-wp-init=\"callbacks.initTriggerButton\" data-wp-on--click=\"actions.showLightbox\" data-wp-style--right=\"state.thisImage.buttonRight\" data-wp-style--top=\"state.thisImage.buttonTop\">\n\t\t\t<svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"12\" height=\"12\" fill=\"none\" viewbox=\"0 0 12 12\">\n\t\t\t\t<path fill=\"#fff\" d=\"M2 0a2 2 0 0 0-2 2v2h1.5V2a.5.5 0 0 1 .5-.5h2V0H2Zm2 10.5H2a.5.5 0 0 1-.5-.5V8H0v2a2 2 0 0 0 2 2h2v-1.5ZM8 12v-1.5h2a.5.5 0 0 0 .5-.5V8H12v2a2 2 0 0 1-2 2H8Zm2-12a2 2 0 0 1 2 2v2h-1.5V2a.5.5 0 0 0-.5-.5H8V0h2Z\"><\/path>\n\t\t\t<\/svg>\n\t\t<\/button><\/figure><\/div><p class=\"wp-block-paragraph\">To test rejection, replace the approval test at the end of <strong>main.py<\/strong> with:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">reject_config = {\n   \"configurable\": {\n      \"thread_id\": \"reject-demo\"\n   }\n}\n\npending = graph.invoke(\n   {\n      \"messages\": [\n         {\n            \"role\": \"user\",\n            \"content\": \"What is the shipping price for 3 kg to EU?\"\n         }\n      ]\n   },\n   config=reject_config,\n)\n\nprint(pending[\"__interrupt__\"][0].value)\n\nrejected_result = graph.invoke(\n   Command(resume=False),\n   config=reject_config,\n)\n\nprint(rejected_result[\"messages\"][-1].content)<\/pre><p class=\"wp-block-paragraph\">Return to the terminal and run <strong>python main.py<\/strong> again. LangGraph follows the rejection path and finishes without running <strong>calculate_shipping<\/strong>.<\/p><div class=\"wp-block-image\"><figure data-wp-context='{\"imageId\":\"6aa215bdabecc\"}' data-wp-interactive=\"core\/image\" data-wp-key=\"6aa215bdabecc\" class=\"aligncenter size-large is-resized wp-lightbox-container\"><img decoding=\"async\" data-wp-class--hide=\"state.isContentHidden\" data-wp-class--show=\"state.isContentVisible\" data-wp-init=\"callbacks.setButtonStyles\" data-wp-on--click=\"actions.showLightbox\" data-wp-on--load=\"callbacks.setButtonStyles\" data-wp-on--pointerdown=\"actions.preloadImage\" data-wp-on--pointerenter=\"actions.preloadImageWithDelay\" data-wp-on--pointerleave=\"actions.cancelPreload\" data-wp-on-window--resize=\"callbacks.setButtonStyles\" src=\"https:\/\/imagedelivery.net\/LqiWLm-3MGbYHtFuUbcBtA\/wp-content\/uploads\/sites\/2\/2026\/09\/local-terminal-langgraph-agent-rejected-tool-call.png\/w=1024,h=1024,fit=scale-down\" alt=\"Terminal showing a rejected calculate_shipping tool call with no action taken\" class=\"wp-image-156585\" style=\"width:640px;height:auto\" title=\"local-terminal-langgraph-agent-rejected-tool-call\"><button class=\"lightbox-trigger\" type=\"button\" aria-haspopup=\"dialog\" data-wp-bind--aria-label=\"state.thisImage.triggerButtonAriaLabel\" data-wp-init=\"callbacks.initTriggerButton\" data-wp-on--click=\"actions.showLightbox\" data-wp-style--right=\"state.thisImage.buttonRight\" data-wp-style--top=\"state.thisImage.buttonTop\">\n\t\t\t<svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"12\" height=\"12\" fill=\"none\" viewbox=\"0 0 12 12\">\n\t\t\t\t<path fill=\"#fff\" d=\"M2 0a2 2 0 0 0-2 2v2h1.5V2a.5.5 0 0 1 .5-.5h2V0H2Zm2 10.5H2a.5.5 0 0 1-.5-.5V8H0v2a2 2 0 0 0 2 2h2v-1.5ZM8 12v-1.5h2a.5.5 0 0 0 .5-.5V8H12v2a2 2 0 0 1-2 2H8Zm2-12a2 2 0 0 1 2 2v2h-1.5V2a.5.5 0 0 0-.5-.5H8V0h2Z\"><\/path>\n\t\t\t<\/svg>\n\t\t<\/button><\/figure><\/div><p>        <div class=\"protip\">\n            <div class=\"protip__heading\">\n                <svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http:\/\/www.w3.org\/2000\/svg\">\n                    <path d=\"M1.49234 23.5024C1.23229 23.5024 0.972242 23.4024 0.782206 23.2123C0.562165 22.9923 0.452144 22.6822 0.502153 22.3722C0.562165 21.9221 1.14227 17.9113 3.00262 16.351C3.63274 15.8209 4.43289 15.5509 5.26305 15.5609C6.09321 15.5909 6.87335 15.9109 7.47347 16.4911C8.6937 17.6913 8.76371 19.6717 7.6435 20.9919C6.0832 22.8523 2.08245 23.4324 1.63237 23.4924C1.59236 23.4924 1.54235 23.4924 1.50234 23.4924L1.49234 23.5024ZM5.16303 17.5613C4.84297 17.5613 4.53291 17.6713 4.29287 17.8813C3.60274 18.4614 3.07264 19.9317 2.75258 21.242C4.06282 20.9219 5.5331 20.3918 6.11321 19.7017C6.55329 19.1716 6.54329 18.3814 6.0832 17.9213C5.85316 17.7013 5.5431 17.5713 5.20304 17.5613C5.19304 17.5613 5.17303 17.5613 5.16303 17.5613ZM11.7243 21.8821C11.4942 21.8821 11.2642 21.8021 11.0841 21.652C10.8541 21.462 10.7241 21.1819 10.7241 20.8819V15.9109L8.08358 13.2705H3.11264C2.81259 13.2705 2.53254 13.1404 2.3425 12.9104C2.15246 12.6803 2.07245 12.3803 2.12246 12.0902C2.19247 11.7102 2.84259 8.36953 4.70294 7.12929C6.33325 6.04909 8.96375 6.49918 10.244 6.80923C11.5442 4.96889 13.2546 3.4286 15.2349 2.33839C17.4553 1.11816 19.9858 0.518051 22.4963 0.498047C23.0464 0.498047 23.4865 0.948132 23.4865 1.49824C23.4865 5.0389 22.3763 9.97983 17.1753 13.7605C17.4853 15.0408 17.9354 17.6613 16.8552 19.2816C15.615 21.1419 12.2744 21.7921 11.8943 21.8621C11.8343 21.8721 11.7743 21.8821 11.7143 21.8821H11.7243ZM12.7245 16.181V19.6016C13.7146 19.2916 14.7948 18.7915 15.2049 18.1814C15.675 17.4812 15.605 16.091 15.385 14.9008C14.5248 15.3808 13.6346 15.8109 12.7245 16.181ZM9.66388 12.0302L11.9643 14.3307C13.1845 13.8306 14.3648 13.2204 15.485 12.5103C19.9358 9.51974 21.2361 5.60901 21.4561 2.53843C19.6157 2.67846 17.8254 3.20856 16.2051 4.09872C14.2847 5.14892 12.6544 6.68921 11.4942 8.54956C10.7841 9.65977 10.174 10.82 9.66388 12.0302ZM4.39289 11.2701H7.81353C8.1936 10.3599 8.63368 9.46974 9.11377 8.60957C7.92355 8.38953 6.51329 8.31952 5.81315 8.78961C5.19304 9.19968 4.70294 10.3099 4.39289 11.2701Z\" fill=\"#673DE6\"\/>\n                <\/svg>\n                <p class=\"protip__title\">\n                    Prevent duplicate actions after an interrupt                <\/p>\n            <\/div>\n            <p class=\"protip__content\"> LangGraph restarts an interrupted node from the beginning after you resume it, so keep code before <strong>interrupt()<\/strong> safe to run more than once. Put one-time actions, such as sending an email or updating a database, after the approval point or in a separate node. For debugging, you can use <strong>interrupt_before<\/strong> or <strong>interrupt_after<\/strong> to pause before or after a specific node.<\/p>\n                    <\/div>\n        \n\n\n\n<\/p><h2 class=\"wp-block-heading\" id=\"h-how-to-deploy-a-langgraph-agent-on-a-vps\">How to deploy a LangGraph agent on a VPS<\/h2><p class=\"wp-block-paragraph\">To deploy a LangGraph agent on a VPS, <strong>move your local project to a Linux server, replace the in-memory checkpointer with persistent checkpoint storage, serve the agent through FastAPI, and use systemd to keep it running<\/strong>.<\/p><p class=\"wp-block-paragraph\">This example uses a Hostinger VPS and the same DeepSeek-based project you built in the previous section.<\/p><h3 class=\"wp-block-heading\">1. Prepare the VPS<\/h3><p class=\"wp-block-paragraph\">Prepare the VPS by choosing a suitable plan and installing Python and the other tools your LangGraph agent needs. You don&rsquo;t need to install DeepSeek because the agent sends model requests to DeepSeek&rsquo;s API.<\/p><p class=\"wp-block-paragraph\">For your LangGraph deployment, the KVM 1 <a href=\"\/ph\/vps-hosting\">VPS hosting plan<\/a> with 1 vCPU, 4 GB RAM, and 50 GB NVMe storage for <strong>\u20b1409.00\/month <\/strong>is a practical starting point.<\/p><p class=\"wp-block-paragraph\">After purchasing the plan, select <strong>Ubuntu 26.04 LTS<\/strong> as the operating system and set a strong password for your VPS.<\/p><p class=\"wp-block-paragraph\">Once Hostinger finishes <a href=\"\/ph\/tutorials\/how-to-set-up-vps\/\">setting up your VPS<\/a>, go to <strong>VPS &rarr; Manage<\/strong> and select <strong>Web console<\/strong> to open the browser-based terminal.<\/p><div class=\"wp-block-image\"><figure data-wp-context='{\"imageId\":\"6aa215bdb0212\"}' data-wp-interactive=\"core\/image\" data-wp-key=\"6aa215bdb0212\" class=\"aligncenter size-large wp-lightbox-container\"><img decoding=\"async\" data-wp-class--hide=\"state.isContentHidden\" data-wp-class--show=\"state.isContentVisible\" data-wp-init=\"callbacks.setButtonStyles\" data-wp-on--click=\"actions.showLightbox\" data-wp-on--load=\"callbacks.setButtonStyles\" data-wp-on--pointerdown=\"actions.preloadImage\" data-wp-on--pointerenter=\"actions.preloadImageWithDelay\" data-wp-on--pointerleave=\"actions.cancelPreload\" data-wp-on-window--resize=\"callbacks.setButtonStyles\" src=\"https:\/\/imagedelivery.net\/LqiWLm-3MGbYHtFuUbcBtA\/wp-content\/uploads\/sites\/2\/2026\/09\/hpanel-vps-overview-web-console-highlighted.png\/w=1024,h=1024,fit=scale-down\" alt=\"Hostinger VPS Overview page with Ubuntu 26.04 LTS and the Web console button\" class=\"wp-image-156579\" title=\"hpanel-vps-overview-web-console-highlighted\"><button class=\"lightbox-trigger\" type=\"button\" aria-haspopup=\"dialog\" data-wp-bind--aria-label=\"state.thisImage.triggerButtonAriaLabel\" data-wp-init=\"callbacks.initTriggerButton\" data-wp-on--click=\"actions.showLightbox\" data-wp-style--right=\"state.thisImage.buttonRight\" data-wp-style--top=\"state.thisImage.buttonTop\">\n\t\t\t<svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"12\" height=\"12\" fill=\"none\" viewbox=\"0 0 12 12\">\n\t\t\t\t<path fill=\"#fff\" d=\"M2 0a2 2 0 0 0-2 2v2h1.5V2a.5.5 0 0 1 .5-.5h2V0H2Zm2 10.5H2a.5.5 0 0 1-.5-.5V8H0v2a2 2 0 0 0 2 2h2v-1.5ZM8 12v-1.5h2a.5.5 0 0 0 .5-.5V8H12v2a2 2 0 0 1-2 2H8Zm2-12a2 2 0 0 1 2 2v2h-1.5V2a.5.5 0 0 0-.5-.5H8V0h2Z\"><\/path>\n\t\t\t<\/svg>\n\t\t<\/button><\/figure><\/div><p class=\"wp-block-paragraph\">Update the server before installing the required packages:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">apt update\napt upgrade -y<\/pre><p class=\"wp-block-paragraph\">Next, install Python, virtual environment support, pip, Git, and curl:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">apt install -y python3 python3-venv python3-pip git curl\npython3 --version<\/pre><p class=\"wp-block-paragraph\">The output should show Python 3.10 or later.<\/p><figure class=\"wp-block-image size-large\"><a href=\"\/ph\/vps-hosting\" target=\"_blank\" rel=\"noreferrer noopener\"><img decoding=\"async\" width=\"1024\" height=\"300\" src=\"https:\/\/imagedelivery.net\/LqiWLm-3MGbYHtFuUbcBtA\/wp-content\/uploads\/sites\/2\/2023\/02\/VPS-hosting-banner.png\/w=1024,h=1024,fit=scale-down\" alt=\"\" class=\"wp-image-77934\" srcset=\"https:\/\/www.hostinger.com\/ph\/tutorials\/wp-content\/uploads\/sites\/44\/2023\/02\/VPS-hosting-banner.png 1024w, https:\/\/www.hostinger.com\/ph\/tutorials\/wp-content\/uploads\/sites\/44\/2023\/02\/VPS-hosting-banner-300x88.png 300w, https:\/\/www.hostinger.com\/ph\/tutorials\/wp-content\/uploads\/sites\/44\/2023\/02\/VPS-hosting-banner-150x44.png 150w, https:\/\/www.hostinger.com\/ph\/tutorials\/wp-content\/uploads\/sites\/44\/2023\/02\/VPS-hosting-banner-768x225.png 768w\" sizes=\"(max-width: 1024px) 100vw, 1024px\" \/><\/a><\/figure><h3 class=\"wp-block-heading\">2. Upload and configure the LangGraph project<\/h3><p class=\"wp-block-paragraph\">To upload and configure your LangGraph project, copy the local files to the VPS, create a virtual environment, install the extra packages needed for deployment, and add your DeepSeek API key.<\/p><p class=\"wp-block-paragraph\">On your local computer, open <strong>requirements.txt<\/strong> and add these lines after the existing dependencies:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">langgraph-checkpoint-sqlite==3.1.1\nfastapi==0.141.1\nuvicorn==0.52.4<\/pre><p class=\"wp-block-paragraph\">Go to your VPS terminal and create the app directory:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">mkdir -p \/opt\/langgraph-agent<\/pre><p class=\"wp-block-paragraph\">Next, open your computer&rsquo;s terminal and move into the <strong>langgraph-agent<\/strong> project folder. Use its actual path if you created the folder somewhere else.<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">cd ~\/langgraph-agent<\/pre><p class=\"wp-block-paragraph\">Copy <strong>main.py<\/strong> and <strong>requirements.txt<\/strong> to the VPS with <strong>scp<\/strong>:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">scp main.py requirements.txt root@your-vps-ip:\/opt\/langgraph-agent\/<\/pre><p class=\"wp-block-paragraph\">Replace <strong>your-vps-ip<\/strong> with your own VPS IP address. You can find it in the VPS <strong>Overview<\/strong> page.<\/p><p class=\"wp-block-paragraph\">Enter your VPS password when prompted.<\/p><div class=\"wp-block-image\"><figure data-wp-context='{\"imageId\":\"6aa215bdb644f\"}' data-wp-interactive=\"core\/image\" data-wp-key=\"6aa215bdb644f\" class=\"aligncenter size-large wp-lightbox-container\"><img decoding=\"async\" data-wp-class--hide=\"state.isContentHidden\" data-wp-class--show=\"state.isContentVisible\" data-wp-init=\"callbacks.setButtonStyles\" data-wp-on--click=\"actions.showLightbox\" data-wp-on--load=\"callbacks.setButtonStyles\" data-wp-on--pointerdown=\"actions.preloadImage\" data-wp-on--pointerenter=\"actions.preloadImageWithDelay\" data-wp-on--pointerleave=\"actions.cancelPreload\" data-wp-on-window--resize=\"callbacks.setButtonStyles\" src=\"https:\/\/imagedelivery.net\/LqiWLm-3MGbYHtFuUbcBtA\/wp-content\/uploads\/sites\/2\/2026\/09\/local-terminal-langgraph-agent-vps-upload.png\/w=1024,h=1024,fit=scale-down\" alt=\"Terminal showing main.py and requirements.txt uploaded to the VPS with scp\" class=\"wp-image-156590\" title=\"local-terminal-langgraph-agent-vps-upload\"><button class=\"lightbox-trigger\" type=\"button\" aria-haspopup=\"dialog\" data-wp-bind--aria-label=\"state.thisImage.triggerButtonAriaLabel\" data-wp-init=\"callbacks.initTriggerButton\" data-wp-on--click=\"actions.showLightbox\" data-wp-style--right=\"state.thisImage.buttonRight\" data-wp-style--top=\"state.thisImage.buttonTop\">\n\t\t\t<svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"12\" height=\"12\" fill=\"none\" viewbox=\"0 0 12 12\">\n\t\t\t\t<path fill=\"#fff\" d=\"M2 0a2 2 0 0 0-2 2v2h1.5V2a.5.5 0 0 1 .5-.5h2V0H2Zm2 10.5H2a.5.5 0 0 1-.5-.5V8H0v2a2 2 0 0 0 2 2h2v-1.5ZM8 12v-1.5h2a.5.5 0 0 0 .5-.5V8H12v2a2 2 0 0 1-2 2H8Zm2-12a2 2 0 0 1 2 2v2h-1.5V2a.5.5 0 0 0-.5-.5H8V0h2Z\"><\/path>\n\t\t\t<\/svg>\n\t\t<\/button><\/figure><\/div><p class=\"wp-block-paragraph\">Switch to your VPS terminal, move into the app directory, and create a Python virtual environment:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">cd \/opt\/langgraph-agent\npython3 -m venv .venv\nsource .venv\/bin\/activate<\/pre><p class=\"wp-block-paragraph\">Your terminal should now show <strong>(.venv)<\/strong> at the beginning of the prompt. Install the pinned dependencies:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">python -m pip install --upgrade pip\npip install -r requirements.txt<\/pre><p class=\"wp-block-paragraph\">Next, create <strong>.env<\/strong> on the VPS with the <strong>nano<\/strong> text editor:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">nano .env<\/pre><p class=\"wp-block-paragraph\">Add your DeepSeek API key:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">DEEPSEEK_API_KEY=your-deepseek-api-key<\/pre><p class=\"wp-block-paragraph\">Save the file and exit <strong>nano<\/strong> with <strong>Ctrl + X &rarr; Y &rarr; Enter<\/strong>.<\/p><p class=\"wp-block-paragraph\"><a href=\"\/ph\/tutorials\/how-to-change-linux-permissions-and-owners\/\">Modify the file permissions<\/a> so only the owner can read or edit it:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">chmod 600 .env<\/pre><p class=\"wp-block-paragraph\">Finally, verify that Python loads the packages:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">python -c \"import fastapi, uvicorn; from langchain_deepseek import ChatDeepSeek; from langgraph.checkpoint.sqlite import SqliteSaver; print('Deployment dependencies OK')\"<\/pre><p class=\"wp-block-paragraph\">The terminal should print <strong>Deployment dependencies OK<\/strong> without an import error.<\/p><div class=\"wp-block-image\"><figure data-wp-context='{\"imageId\":\"6aa215bdba22c\"}' data-wp-interactive=\"core\/image\" data-wp-key=\"6aa215bdba22c\" class=\"aligncenter size-large wp-lightbox-container\"><img decoding=\"async\" data-wp-class--hide=\"state.isContentHidden\" data-wp-class--show=\"state.isContentVisible\" data-wp-init=\"callbacks.setButtonStyles\" data-wp-on--click=\"actions.showLightbox\" data-wp-on--load=\"callbacks.setButtonStyles\" data-wp-on--pointerdown=\"actions.preloadImage\" data-wp-on--pointerenter=\"actions.preloadImageWithDelay\" data-wp-on--pointerleave=\"actions.cancelPreload\" data-wp-on-window--resize=\"callbacks.setButtonStyles\" src=\"https:\/\/imagedelivery.net\/LqiWLm-3MGbYHtFuUbcBtA\/wp-content\/uploads\/sites\/2\/2026\/09\/vps-terminal-langgraph-agent-dependency-check.png\/w=1024,h=1024,fit=scale-down\" alt=\"VPS terminal showing the deployment dependency check completed successfully\" class=\"wp-image-156591\" title=\"vps-terminal-langgraph-agent-dependency-check\"><button class=\"lightbox-trigger\" type=\"button\" aria-haspopup=\"dialog\" data-wp-bind--aria-label=\"state.thisImage.triggerButtonAriaLabel\" data-wp-init=\"callbacks.initTriggerButton\" data-wp-on--click=\"actions.showLightbox\" data-wp-style--right=\"state.thisImage.buttonRight\" data-wp-style--top=\"state.thisImage.buttonTop\">\n\t\t\t<svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"12\" height=\"12\" fill=\"none\" viewbox=\"0 0 12 12\">\n\t\t\t\t<path fill=\"#fff\" d=\"M2 0a2 2 0 0 0-2 2v2h1.5V2a.5.5 0 0 1 .5-.5h2V0H2Zm2 10.5H2a.5.5 0 0 1-.5-.5V8H0v2a2 2 0 0 0 2 2h2v-1.5ZM8 12v-1.5h2a.5.5 0 0 0 .5-.5V8H12v2a2 2 0 0 1-2 2H8Zm2-12a2 2 0 0 1 2 2v2h-1.5V2a.5.5 0 0 0-.5-.5H8V0h2Z\"><\/path>\n\t\t\t<\/svg>\n\t\t<\/button><\/figure><\/div><h3 class=\"wp-block-heading\">3. Add persistent checkpoint storage<\/h3><p class=\"wp-block-paragraph\">Add persistent checkpoint storage by replacing <strong>InMemorySaver<\/strong> with <strong>SqliteSaver<\/strong>, which saves conversation history and paused approval requests to a database file instead of keeping them only in memory.<\/p><p class=\"wp-block-paragraph\">In your VPS terminal, open <strong>main.py<\/strong>:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">nano main.py<\/pre><p class=\"wp-block-paragraph\">At the top of the file, add:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">import sqlite3<\/pre><p class=\"wp-block-paragraph\">Then replace the existing <strong>InMemorySaver<\/strong> import:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">from langgraph.checkpoint.memory import InMemorySaver<\/pre><p class=\"wp-block-paragraph\">with:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">from langgraph.checkpoint.sqlite import SqliteSaver<\/pre><p class=\"wp-block-paragraph\">Find the current checkpointer setup:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">memory = InMemorySaver()\n\ngraph = builder.compile(checkpointer=memory)<\/pre><p class=\"wp-block-paragraph\">Replace it with:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">connection = sqlite3.connect(\n   \"\/opt\/langgraph-agent\/checkpoints.sqlite\",\n   check_same_thread=False,\n)\n\ncheckpointer = SqliteSaver(connection)\n\ngraph = builder.compile(checkpointer=checkpointer)<\/pre><p class=\"wp-block-paragraph\"><strong>SqliteSaver<\/strong> now saves the agent&rsquo;s checkpoints in <strong>\/opt\/langgraph-agent\/checkpoints.sqlite<\/strong>, so they remain available after the Python process restarts.<\/p><p class=\"wp-block-paragraph\">Keep <strong>check_same_thread=False<\/strong> so the FastAPI service you&rsquo;ll add next can handle requests in different threads.<\/p><p class=\"wp-block-paragraph\">Save <strong>main.py<\/strong>, then run the agent:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">python main.py<\/pre><p class=\"wp-block-paragraph\">You should see the pending shipping request, followed by <strong>The tool call was rejected, so no action was taken.<\/strong><\/p><p class=\"wp-block-paragraph\">Confirm that the checkpoint database was created:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">ls -lh \/opt\/langgraph-agent\/checkpoints.sqlite<\/pre><p class=\"wp-block-paragraph\">The output should list <strong>checkpoints.sqlite<\/strong>.<\/p><div class=\"wp-block-image\"><figure data-wp-context='{\"imageId\":\"6aa215bdbe00d\"}' data-wp-interactive=\"core\/image\" data-wp-key=\"6aa215bdbe00d\" class=\"aligncenter size-large wp-lightbox-container\"><img decoding=\"async\" data-wp-class--hide=\"state.isContentHidden\" data-wp-class--show=\"state.isContentVisible\" data-wp-init=\"callbacks.setButtonStyles\" data-wp-on--click=\"actions.showLightbox\" data-wp-on--load=\"callbacks.setButtonStyles\" data-wp-on--pointerdown=\"actions.preloadImage\" data-wp-on--pointerenter=\"actions.preloadImageWithDelay\" data-wp-on--pointerleave=\"actions.cancelPreload\" data-wp-on-window--resize=\"callbacks.setButtonStyles\" src=\"https:\/\/imagedelivery.net\/LqiWLm-3MGbYHtFuUbcBtA\/wp-content\/uploads\/sites\/2\/2026\/09\/vps-terminal-langgraph-agent-sqlite-checkpoint.png\/w=1024,h=1024,fit=scale-down\" alt=\"VPS terminal showing the SQLite checkpoint file created after a rejected tool call\" class=\"wp-image-156593\" title=\"vps-terminal-langgraph-agent-sqlite-checkpoint\"><button class=\"lightbox-trigger\" type=\"button\" aria-haspopup=\"dialog\" data-wp-bind--aria-label=\"state.thisImage.triggerButtonAriaLabel\" data-wp-init=\"callbacks.initTriggerButton\" data-wp-on--click=\"actions.showLightbox\" data-wp-style--right=\"state.thisImage.buttonRight\" data-wp-style--top=\"state.thisImage.buttonTop\">\n\t\t\t<svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"12\" height=\"12\" fill=\"none\" viewbox=\"0 0 12 12\">\n\t\t\t\t<path fill=\"#fff\" d=\"M2 0a2 2 0 0 0-2 2v2h1.5V2a.5.5 0 0 1 .5-.5h2V0H2Zm2 10.5H2a.5.5 0 0 1-.5-.5V8H0v2a2 2 0 0 0 2 2h2v-1.5ZM8 12v-1.5h2a.5.5 0 0 0 .5-.5V8H12v2a2 2 0 0 1-2 2H8Zm2-12a2 2 0 0 1 2 2v2h-1.5V2a.5.5 0 0 0-.5-.5H8V0h2Z\"><\/path>\n\t\t\t<\/svg>\n\t\t<\/button><\/figure><\/div><h3 class=\"wp-block-heading\">4. Serve and test the agent with FastAPI<\/h3><p class=\"wp-block-paragraph\">Serve and test your LangGraph agent with FastAPI by adding API endpoints, starting the app with Uvicorn, and sending test requests from the VPS.<\/p><p class=\"wp-block-paragraph\">Open <strong>main.py<\/strong> in your VPS terminal, then add these imports with the existing imports:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">from fastapi import FastAPI\nfrom pydantic import BaseModel<\/pre><p class=\"wp-block-paragraph\">Remove the rejection test at the end of <strong>main.py<\/strong>, starting with <strong>reject_config = {<\/strong> and ending with the final <strong>print(rejected_result&hellip;)<\/strong> line.<\/p><p class=\"wp-block-paragraph\">Below <strong>graph = builder.compile(checkpointer=checkpointer)<\/strong>, add the FastAPI app and the request models for chat and approval:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">app = FastAPI()\n\n\nclass ChatRequest(BaseModel):\n   message: str\n   thread_id: str\n\n\nclass ApprovalRequest(BaseModel):\n   thread_id: str\n   approved: bool<\/pre><p class=\"wp-block-paragraph\">Then add the <strong>\/chat<\/strong> endpoint to <a href=\"\/ph\/tutorials\/what-is-a-public-api\/\">expose your app through an API<\/a>:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">@app.post(\"\/chat\")\ndef chat(request: ChatRequest):\n   config = {\n      \"configurable\": {\n         \"thread_id\": request.thread_id\n      }\n   }\n\n   result = graph.invoke(\n      {\n         \"messages\": [\n            {\n               \"role\": \"user\",\n               \"content\": request.message,\n            }\n         ]\n      },\n      config=config,\n   )\n\n   if \"__interrupt__\" in result:\n      return {\n         \"status\": \"needs_approval\",\n         \"request\": result[\"__interrupt__\"][0].value,\n      }\n\n   return {\n      \"status\": \"complete\",\n      \"message\": result[\"messages\"][-1].content,\n   }<\/pre><p class=\"wp-block-paragraph\">The <strong>\/chat<\/strong> endpoint passes the message to your graph. It returns DeepSeek&rsquo;s answer for a direct response or <strong>needs_approval<\/strong> when a tool call pauses for your decision.<\/p><p class=\"wp-block-paragraph\">Immediately below the <strong>\/chat<\/strong> endpoint, add <strong>\/approve<\/strong>:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">@app.post(\"\/approve\")\ndef approve(request: ApprovalRequest):\n   config = {\n      \"configurable\": {\n         \"thread_id\": request.thread_id\n      }\n   }\n\n   result = graph.invoke(\n      Command(resume=request.approved),\n      config=config,\n   )\n\n   return {\n      \"status\": \"complete\",\n      \"message\": result[\"messages\"][-1].content,\n   }<\/pre><p class=\"wp-block-paragraph\">The <strong>\/approve<\/strong> endpoint resumes the paused request with the same <strong>thread_id<\/strong>. Set <strong>approved<\/strong> to <strong>true<\/strong> to run the tool or <strong>false<\/strong> to reject it.<\/p><p class=\"wp-block-paragraph\">After saving <strong>main.py<\/strong>, start the FastAPI app with Uvicorn:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">cd \/opt\/langgraph-agent\nsource .venv\/bin\/activate\nuvicorn main:app --host 127.0.0.1 --port 8000 --workers 1<\/pre><p class=\"wp-block-paragraph\">Using <strong>127.0.0.1<\/strong> keeps the API accessible only from the VPS. Keep this terminal open while Uvicorn runs, then open a new VPS terminal window.<\/p><p class=\"wp-block-paragraph\">There, send a request that doesn&rsquo;t need a tool:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">curl -X POST http:\/\/127.0.0.1:8000\/chat \n   -H \"Content-Type: application\/json\" \n   -d '{\"message\":\"What does LangGraph state do?\",\"thread_id\":\"api-test-1\"}'<\/pre><p class=\"wp-block-paragraph\">The response should contain <strong>&ldquo;status&rdquo;:&rdquo;complete&rdquo;<\/strong> and DeepSeek&rsquo;s answer.<\/p><p class=\"wp-block-paragraph\">Next, send a shipping request to test the approval flow:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">curl -X POST http:\/\/127.0.0.1:8000\/chat \n   -H \"Content-Type: application\/json\" \n   -d '{\"message\":\"What is the shipping price for 3 kg to EU?\",\"thread_id\":\"api-test-2\"}'<\/pre><p class=\"wp-block-paragraph\">The response should contain <strong>&ldquo;status&rdquo;:&rdquo;needs_approval&rdquo;<\/strong> and the pending <strong>calculate_shipping<\/strong> tool call.<\/p><p class=\"wp-block-paragraph\">Approve the request through <strong>\/approve<\/strong>:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">curl -X POST http:\/\/127.0.0.1:8000\/approve \n   -H \"Content-Type: application\/json\" \n   -d '{\"thread_id\":\"api-test-2\",\"approved\":true}'<\/pre><p class=\"wp-block-paragraph\">The response should contain <strong>&ldquo;status&rdquo;:&rdquo;complete&rdquo;<\/strong>, and DeepSeek&rsquo;s answer should include the shipping price of <strong>$13.25<\/strong>.<\/p><div class=\"wp-block-image\"><figure data-wp-context='{\"imageId\":\"6aa215bdc1dac\"}' data-wp-interactive=\"core\/image\" data-wp-key=\"6aa215bdc1dac\" class=\"aligncenter size-large wp-lightbox-container\"><img decoding=\"async\" data-wp-class--hide=\"state.isContentHidden\" data-wp-class--show=\"state.isContentVisible\" data-wp-init=\"callbacks.setButtonStyles\" data-wp-on--click=\"actions.showLightbox\" data-wp-on--load=\"callbacks.setButtonStyles\" data-wp-on--pointerdown=\"actions.preloadImage\" data-wp-on--pointerenter=\"actions.preloadImageWithDelay\" data-wp-on--pointerleave=\"actions.cancelPreload\" data-wp-on-window--resize=\"callbacks.setButtonStyles\" src=\"https:\/\/imagedelivery.net\/LqiWLm-3MGbYHtFuUbcBtA\/wp-content\/uploads\/sites\/2\/2026\/09\/vps-terminal-langgraph-agent-fastapi-approval-flow.png\/w=1024,h=1024,fit=scale-down\" alt=\"VPS terminal showing FastAPI approval flow from needs_approval to .25\" class=\"wp-image-156592\" title=\"vps-terminal-langgraph-agent-fastapi-approval-flow\"><button class=\"lightbox-trigger\" type=\"button\" aria-haspopup=\"dialog\" data-wp-bind--aria-label=\"state.thisImage.triggerButtonAriaLabel\" data-wp-init=\"callbacks.initTriggerButton\" data-wp-on--click=\"actions.showLightbox\" data-wp-style--right=\"state.thisImage.buttonRight\" data-wp-style--top=\"state.thisImage.buttonTop\">\n\t\t\t<svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"12\" height=\"12\" fill=\"none\" viewbox=\"0 0 12 12\">\n\t\t\t\t<path fill=\"#fff\" d=\"M2 0a2 2 0 0 0-2 2v2h1.5V2a.5.5 0 0 1 .5-.5h2V0H2Zm2 10.5H2a.5.5 0 0 1-.5-.5V8H0v2a2 2 0 0 0 2 2h2v-1.5ZM8 12v-1.5h2a.5.5 0 0 0 .5-.5V8H12v2a2 2 0 0 1-2 2H8Zm2-12a2 2 0 0 1 2 2v2h-1.5V2a.5.5 0 0 0-.5-.5H8V0h2Z\"><\/path>\n\t\t\t<\/svg>\n\t\t<\/button><\/figure><\/div><h3 class=\"wp-block-heading\">5. Keep the agent running with systemd<\/h3><p class=\"wp-block-paragraph\">Keep your LangGraph agent running with <strong>systemd<\/strong> so the API starts automatically after a server reboot and restarts if Uvicorn fails.<\/p><p class=\"wp-block-paragraph\">Return to the VPS terminal where Uvicorn is running and press <strong>Ctrl + C<\/strong> to stop it.<\/p><p class=\"wp-block-paragraph\">Create a separate Linux user for the service and give it ownership of the app directory:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">useradd --system --home \/opt\/langgraph-agent --shell \/usr\/sbin\/nologin langgraph\nchown -R langgraph:langgraph \/opt\/langgraph-agent<\/pre><p class=\"wp-block-paragraph\">Next, create the <strong>systemd<\/strong> service file:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">nano \/etc\/systemd\/system\/langgraph-agent.service<\/pre><p class=\"wp-block-paragraph\">Add:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">[Unit]\nDescription=LangGraph agent API\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nUser=langgraph\nGroup=langgraph\nWorkingDirectory=\/opt\/langgraph-agent\nEnvironmentFile=\/opt\/langgraph-agent\/.env\nEnvironment=LANGGRAPH_STRICT_MSGPACK=true\nExecStart=\/opt\/langgraph-agent\/.venv\/bin\/uvicorn main:app --host 127.0.0.1 --port 8000 --workers 1\nRestart=on-failure\nRestartSec=5\n\n[Install]\nWantedBy=multi-user.target<\/pre><p class=\"wp-block-paragraph\">The service reads your DeepSeek API key from <strong>.env<\/strong>. <strong>LANGGRAPH_STRICT_MSGPACK=true<\/strong> limits what LangGraph is allowed to load from saved checkpoints.<\/p><p class=\"wp-block-paragraph\">Save the file. Then, reload <strong>systemd<\/strong>, start the service, and enable it at boot:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">systemctl daemon-reload\nsystemctl enable --now langgraph-agent\nsystemctl status langgraph-agent<\/pre><p class=\"wp-block-paragraph\">The status should show <strong>active (running)<\/strong>.<\/p><div class=\"wp-block-image\"><figure data-wp-context='{\"imageId\":\"6aa215bdc5b04\"}' data-wp-interactive=\"core\/image\" data-wp-key=\"6aa215bdc5b04\" class=\"aligncenter size-large wp-lightbox-container\"><img decoding=\"async\" data-wp-class--hide=\"state.isContentHidden\" data-wp-class--show=\"state.isContentVisible\" data-wp-init=\"callbacks.setButtonStyles\" data-wp-on--click=\"actions.showLightbox\" data-wp-on--load=\"callbacks.setButtonStyles\" data-wp-on--pointerdown=\"actions.preloadImage\" data-wp-on--pointerenter=\"actions.preloadImageWithDelay\" data-wp-on--pointerleave=\"actions.cancelPreload\" data-wp-on-window--resize=\"callbacks.setButtonStyles\" src=\"https:\/\/imagedelivery.net\/LqiWLm-3MGbYHtFuUbcBtA\/wp-content\/uploads\/sites\/2\/2026\/09\/vps-terminal-langgraph-agent-systemd-status.png\/w=1024,h=1024,fit=scale-down\" alt=\"VPS terminal showing the langgraph-agent systemd service active and running\" class=\"wp-image-156594\" title=\"vps-terminal-langgraph-agent-systemd-status\"><button class=\"lightbox-trigger\" type=\"button\" aria-haspopup=\"dialog\" data-wp-bind--aria-label=\"state.thisImage.triggerButtonAriaLabel\" data-wp-init=\"callbacks.initTriggerButton\" data-wp-on--click=\"actions.showLightbox\" data-wp-style--right=\"state.thisImage.buttonRight\" data-wp-style--top=\"state.thisImage.buttonTop\">\n\t\t\t<svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"12\" height=\"12\" fill=\"none\" viewbox=\"0 0 12 12\">\n\t\t\t\t<path fill=\"#fff\" d=\"M2 0a2 2 0 0 0-2 2v2h1.5V2a.5.5 0 0 1 .5-.5h2V0H2Zm2 10.5H2a.5.5 0 0 1-.5-.5V8H0v2a2 2 0 0 0 2 2h2v-1.5ZM8 12v-1.5h2a.5.5 0 0 0 .5-.5V8H12v2a2 2 0 0 1-2 2H8Zm2-12a2 2 0 0 1 2 2v2h-1.5V2a.5.5 0 0 0-.5-.5H8V0h2Z\"><\/path>\n\t\t\t<\/svg>\n\t\t<\/button><\/figure><\/div><p class=\"wp-block-paragraph\">Check the service logs with <a href=\"\/ph\/tutorials\/journalctl-command\/\">journalctl<\/a> if <strong>systemctl status<\/strong> displays a failure:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">journalctl -u langgraph-agent -n 50 --no-pager<\/pre><p class=\"wp-block-paragraph\">In the other VPS terminal, verify that the API still responds:<\/p><pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">curl -X POST http:\/\/127.0.0.1:8000\/chat \n   -H \"Content-Type: application\/json\" \n   -d '{\"message\":\"What does LangGraph state do?\",\"thread_id\":\"service-test\"}'<\/pre><p class=\"wp-block-paragraph\">The response should contain <strong>&ldquo;status&rdquo;:&rdquo;complete&rdquo;<\/strong> and DeepSeek&rsquo;s answer.<\/p><h2 class=\"wp-block-heading\" id=\"h-how-to-improve-your-langgraph-agent-after-deployment\">How to improve your LangGraph agent after deployment<\/h2><p class=\"wp-block-paragraph\">Improve your LangGraph agent after deployment by <strong>retesting every graph path after changes, keeping conversations on separate thread IDs, using approval only for sensitive actions, backing up checkpoints, and updating pinned packages carefully<\/strong>.<\/p><ul class=\"wp-block-list\">\n<li><strong>Retest every graph path after changes.<\/strong> Check the direct-response, tool, approval, and rejection paths after you update the agent. This helps you catch broken routes or tool calls before deployment. Reuse the same test prompts and <strong>curl<\/strong> requests you already used, then add new tests as you introduce more routes or expand into <a href=\"\/ph\/tutorials\/multi-agent-systems\/\">workflows with multiple AI agents<\/a>.<\/li>\n\n\n\n<li><strong>Keep conversations on separate thread IDs.<\/strong> Use a different <strong>thread_id<\/strong> for each new conversation so unrelated messages don&rsquo;t end up in the same saved history. Create a new <strong>thread_id<\/strong> at the start of a conversation, then reuse it only for later messages in that same conversation.<\/li>\n\n\n\n<li><strong>Use approval for sensitive actions.<\/strong> Add an approval interrupt before tools that perform actions you want to review before they run, such as sending messages, creating orders, deleting data, or changing records. Skip approval for read-only actions that don&rsquo;t modify data or trigger an external action, such as retrieving information<\/li>\n\n\n\n<li><strong>Back up the checkpoint database.<\/strong> Keep a copy of <strong>checkpoints.sqlite<\/strong> before changing anything that affects saved state, such as the checkpoint setup or the deployed app. Losing this file removes the conversation history your agent relies on, so store the backup outside the VPS, like on your local computer or another backup server.<\/li>\n\n\n\n<li><strong>Update pinned packages carefully.<\/strong> Change the versions in <strong>requirements.txt<\/strong> one at a time instead of upgrading every package at once, because updates can affect how LangGraph, LangChain, FastAPI, or Uvicorn works. Test the updated agent locally before redeploying it to your VPS.<\/li>\n<\/ul>\n","protected":false},"excerpt":{"rendered":"<p>To build an AI agent in Python with LangGraph, define its shared state and model node, connect them in a graph, then add tools, memory, streaming, and human approval. You&rsquo;ll build the LangGraph agent in eight stages: After developing your LangGraph agent locally, you&rsquo;ll deploy it to a Linux virtual private server (VPS) so it [&#8230;]<\/p>\n<p><a class=\"btn btn-secondary understrap-read-more-link\" href=\"\/ph\/tutorials\/langgraph-tutorial\/\">Read More&#8230;<\/a><\/p>\n","protected":false},"author":411,"featured_media":133991,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"LangGraph tutorial: Build an AI agent in Python","rank_math_description":"Follow this LangGraph tutorial to build a Python AI agent with tools, memory, streaming, human approval, persistence, then deploy it to a Linux server.","rank_math_focus_keyword":"LangGraph tutorial","footnotes":""},"categories":[22663],"tags":[],"class_list":["post-133990","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-vps"],"hreflangs":[{"locale":"en-US","link":"https:\/\/www.hostinger.com\/tutorials\/langgraph-tutorial\/","default":1},{"locale":"en-PH","link":"https:\/\/www.hostinger.com\/ph\/tutorials\/langgraph-tutorial\/","default":0},{"locale":"en-MY","link":"https:\/\/www.hostinger.com\/my\/tutorials\/langgraph-tutorial\/","default":0},{"locale":"en-GB","link":"https:\/\/www.hostinger.com\/uk\/tutorials\/langgraph-tutorial\/","default":0},{"locale":"en-IN","link":"https:\/\/www.hostinger.com\/in\/tutorials\/langgraph-tutorial\/","default":0},{"locale":"en-CA","link":"https:\/\/www.hostinger.com\/ca\/tutorials\/langgraph-tutorial\/","default":0},{"locale":"en-AU","link":"https:\/\/www.hostinger.com\/au\/tutorials\/langgraph-tutorial\/","default":0},{"locale":"en-NG","link":"https:\/\/www.hostinger.com\/ng\/tutorials\/langgraph-tutorial\/","default":0}],"_links":{"self":[{"href":"https:\/\/www.hostinger.com\/ph\/tutorials\/wp-json\/wp\/v2\/posts\/133990","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.hostinger.com\/ph\/tutorials\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.hostinger.com\/ph\/tutorials\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.hostinger.com\/ph\/tutorials\/wp-json\/wp\/v2\/users\/411"}],"replies":[{"embeddable":true,"href":"https:\/\/www.hostinger.com\/ph\/tutorials\/wp-json\/wp\/v2\/comments?post=133990"}],"version-history":[{"count":0,"href":"https:\/\/www.hostinger.com\/ph\/tutorials\/wp-json\/wp\/v2\/posts\/133990\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.hostinger.com\/ph\/tutorials\/wp-json\/wp\/v2\/media\/133991"}],"wp:attachment":[{"href":"https:\/\/www.hostinger.com\/ph\/tutorials\/wp-json\/wp\/v2\/media?parent=133990"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.hostinger.com\/ph\/tutorials\/wp-json\/wp\/v2\/categories?post=133990"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.hostinger.com\/ph\/tutorials\/wp-json\/wp\/v2\/tags?post=133990"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}