What you will build
Printing “waiting for approval” is not a real pause. A later command needs to find the same pending request and supply a decision. We will save graph progress in a local SQLite file and address that saved history by a thread ID.
This lesson is intentionally small: the draft is a fixed sentence, and the only node asks for review. Run the start, status, and approve commands as separate Python processes. That makes persistence observable instead of assuming it works because one process stayed alive.
By the end: Pause a review, exit Python, and resume the same saved workflow in a later process.
- What you will build
- What you need before starting
- Understand the idea before the code
- The workflow we are building
- Create the lesson folder
- Complete code for this article
- Run it and compare the result
- Walk through what just happened
- Change one thing yourself
- If your result is different
- What you can now explain
What you need before starting
You need basic Python: variables, functions, dictionaries, loops, and imports. When this lesson introduces a class or a library-specific term, its role is explained below.
Use Python 3.12. These examples were executed on Windows with Python 3.12.4. Use the shown interpreter consistently when installing packages and running code.
You do not need to run an earlier article to make this part execute. The complete files and data used here are supplied below. The series order is for learning the ideas progressively.
Understand the idea before the code
Ordinary Python variables disappear when their process ends. Persistence means writing information somewhere it can be loaded later. In this lesson, SQLite is a small database kept in a file; it holds saved workflow progress on this machine.
A checkpoint is a saved record of a graph's state and execution progress. A checkpointer is the component that saves and loads those records. SqliteSaver is the checkpointer implementation used here. A checkpoint is not the Python function code and not a language model's learned weights.
thread_id is the key identifying which saved workflow run to load. It is an application identifier, not a Python operating-system thread and not proof that a user is authorized. Reuse the same ID and checkpoint file to continue the same saved ticket. A different ID selects a different history.
interrupt(...) is LangGraph's pause point. The payload in parentheses is the review information exposed to the caller. Payload just means the data carried by a request or event. The graph pauses instead of treating that payload as an approval answer.
The workflow we are building
The boxes are named Python operations. Solid arrows are fixed transitions; dashed arrows show possible destinations chosen by routing functions. The image comes from this lesson's actual compiled graph. One particular input follows only the applicable route. Open the full-size graph.
Create the lesson folder
Create this folder and keep the filenames exactly as shown. In particular, a local module is an ordinary file beside the program, not an extra package you must guess how to install.
lesson-folder/
lesson.py
graph_picture.py
requirements.txt
Save requirements.txt
These are the external Python packages for this lesson. The local files above are included in the article; they are not pip packages.
langgraph==1.2.16
langgraph-checkpoint-sqlite==3.1.2
python -m venv .venv
.venv\Scripts\python.exe -m pip install -r requirements.txt
On Linux/macOS, use python3 -m venv .venv and .venv/bin/python -m pip install -r requirements.txt. Installation requires internet access. The default examples do not call a paid API.
Complete code for this article
Save this complete file as lesson.py
This is the complete program, including imports, data, function definitions, and the entry point.
from graph_picture import save_graph
"""Article 5: stop the process and resume a review in a later process."""
import argparse
import json
import os
from pathlib import Path
from typing import TypedDict
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt
class State(TypedDict, total=False):
reply: str
approved: bool
def review(state):
decision = interrupt({"reply": state["reply"], "notice": "No email will be sent."})
if not isinstance(decision, dict) or type(decision.get("approve")) is not bool:
raise ValueError("approve must be a boolean")
return {"approved": decision["approve"]}
def build(saver):
builder = StateGraph(State)
builder.add_node("review", review)
builder.add_edge(START, "review")
builder.add_edge("review", END)
compiled = builder.compile(checkpointer=saver)
save_graph(compiled, 5)
return compiled
def main():
parser = argparse.ArgumentParser()
parser.add_argument("action", choices=["start", "status", "approve", "reject"])
parser.add_argument("--id", default="lesson-5")
args = parser.parse_args()
directory = Path(os.environ.get("CODETIPS_DATA_DIR", ".runtime"))
directory.mkdir(parents=True, exist_ok=True)
config = {"configurable": {"thread_id": args.id}}
with SqliteSaver.from_conn_string(str(directory / "lesson5.sqlite")) as saver:
graph = build(saver)
snapshot = graph.get_state(config)
if args.action == "start":
if snapshot.values:
parser.error("Use a new ID; this review already exists.")
graph.invoke(
{"reply": "Check your Python environment.", "approved": False}, config
)
elif not snapshot.values:
parser.error("No review found.")
elif args.action in {"approve", "reject"}:
if "review" not in snapshot.next:
parser.error("This review is already finished.")
graph.invoke(Command(resume={"approve": args.action == "approve"}), config)
snapshot = graph.get_state(config)
print(
json.dumps(
{"state": snapshot.values, "next": list(snapshot.next)}, indent=2
)
)
if __name__ == "__main__":
main()
Save this complete file as graph_picture.py
This is the complete local diagram renderer imported by lesson.py. Save it in the same folder. It draws the compiled graph; it does not decide the workflow or contact an image service.
import html
from pathlib import Path
ROLES = {
"__start__": "Enter the workflow",
"__end__": "This run is finished",
"clean": "Normalize the question",
"draft": "Prepare a reply",
"classify": "Choose a topic category",
"technical": "Technical branch",
"human": "Ask a mentor to review",
"lookup": "Retrieve local evidence",
"check": "Check source-ID structure",
"revise": "Allow one more draft",
"review": "Pause inside this node",
"approval": "Wait for a human decision",
"queue": "Write one local outbox row",
"escalate": "Finish with needs_human",
}
def positions(part):
if part == 2:
return {
"__start__": (320, 115),
"clean": (320, 250),
"draft": (320, 385),
"__end__": (320, 520),
}
if part == 3:
return {
"__start__": (420, 115),
"classify": (420, 250),
"human": (120, 410),
"technical": (720, 410),
"__end__": (420, 590),
}
if part == 5:
return {"__start__": (320, 115), "review": (320, 265), "__end__": (320, 415)}
return {
"__start__": (420, 115),
"classify": (420, 250),
"lookup": (420, 385),
"draft": (420, 520),
"check": (420, 655),
"revise": (790, 655),
"human": (70, 850),
"escalate": (70, 850),
"approval": (420, 850),
"queue": (420, 1035),
"__end__": (420, 1215),
}
def draw_svg(structure, part):
layout = positions(part)
node_width, node_height = (225, 83)
width = 850 if part in {2, 5} else 1080
height = max((layout[name][1] for name in structure.nodes)) + node_height + 100
chunks = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}" role="img" aria-labelledby="title desc">',
f'<title id="title">Part {part}: graph generated from the compiled lesson code</title>',
'<desc id="desc">Solid arrows are fixed edges. Dashed arrows are conditional destinations. Node positions are chosen for readability.</desc>',
'<defs><marker id="solid" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto"><path d="M0 0 L10 5 L0 10z" fill="#526c75"/></marker><marker id="conditional" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto"><path d="M0 0 L10 5 L0 10z" fill="#b54625"/></marker></defs>',
'<rect width="100%" height="100%" fill="#faf9f5"/>',
'<text x="30" y="35" font-family="Arial,sans-serif" font-size="16" font-weight="bold" fill="#b54625">CODETIPS ACADEMY / GENERATED FROM CODE</text>',
f'<text x="30" y="73" font-family="Arial,sans-serif" font-size="28" font-weight="bold" fill="#183443">Part {part} — the compiled workflow</text>',
]
edge_data = []
for index, edge in enumerate(structure.edges):
sx, sy = layout[edge.source]
tx, ty = layout[edge.target]
if sx == tx:
points = [
(sx + node_width / 2, sy + node_height),
(tx + node_width / 2, ty),
]
elif ty < sy:
points = [
(sx + node_width / 2, sy),
(sx + node_width / 2, ty + node_height / 2),
(tx + node_width, ty + node_height / 2),
]
else:
leftward = tx < sx
x1 = sx if leftward else sx + node_width
x2 = tx + node_width if leftward else tx
y1 = sy + node_height / 2
y2 = ty + node_height / 2
middle = (x1 + x2) / 2 + (index % 3 - 1) * 12
points = [(x1, y1), (middle, y1), (middle, y2), (x2, y2)]
kind = "conditional" if edge.conditional else "solid"
color = "#b54625" if edge.conditional else "#526c75"
dash = ' stroke-dasharray="9 6"' if edge.conditional else ""
chunks.append(
'<polyline points="'
+ " ".join((f"{x:g},{y:g}" for x, y in points))
+ f'" fill="none" stroke="{color}" stroke-width="3"{dash} marker-end="url(#{kind})"/>'
)
edge_data.append(
{
"source": edge.source,
"target": edge.target,
"conditional": bool(edge.conditional),
}
)
for name in structure.nodes:
x, y = layout[name]
display = {"__start__": "START", "__end__": "END"}.get(name, name)
highlight = name in {"review", "approval", "check"}
chunks.append(
f'<rect x="{x}" y="{y}" width="{node_width}" height="{node_height}" rx="12" fill="{('#f8e9df' if highlight else '#eaf0eb')}" stroke="#c9d6ce" stroke-width="2"/>'
)
chunks.append(
f'<text x="{x + 14}" y="{y + 32}" font-family="Consolas,monospace" font-size="23" font-weight="bold" fill="#183443">{html.escape(display)}</text>'
)
chunks.append(
f'<text x="{x + 14}" y="{y + 60}" font-family="Arial,sans-serif" font-size="15" fill="#526c75">{html.escape(ROLES.get(name, name))}</text>'
)
chunks += [
f'<path d="M30 {height - 55} H{width - 30}" stroke="#d9dfd8"/>',
f'<text x="30" y="{height - 25}" font-family="Arial,sans-serif" font-size="16" fill="#526c75">Solid: fixed edge. Orange dashed: conditional destination. See the routing explanation below.</text>',
"</svg>",
]
return (
"\n".join(chunks),
{"part": part, "nodes": list(structure.nodes), "edges": edge_data},
)
def save_graph(graph, part):
structure = graph.get_graph()
svg, _ = draw_svg(structure, part)
Path("workflow.svg").write_text(svg, encoding="utf-8")
Path("workflow.mmd").write_text(structure.draw_mermaid(), encoding="utf-8")
Run it and compare the result
Run commands from the lesson folder. In the commands below, python means the environment you installed into. On Windows, substitute .venv\Scripts\python.exe; on Linux/macOS substitute .venv/bin/python. Do not create the environment with one interpreter and run the lesson with another.
Run 1: python lesson.py start --id review-demo
python lesson.py start --id review-demo
Captured standard output:
{
"state": {
"reply": "Check your Python environment.",
"approved": false
},
"next": [
"review"
]
}
Run 2: python lesson.py status --id review-demo
python lesson.py status --id review-demo
Captured standard output:
{
"state": {
"reply": "Check your Python environment.",
"approved": false
},
"next": [
"review"
]
}
Run 3: python lesson.py approve --id review-demo
python lesson.py approve --id review-demo
Captured standard output:
{
"state": {
"reply": "Check your Python environment.",
"approved": true
},
"next": []
}
The program also writes workflow.svg and workflow.mmd into the current folder. Open the SVG in a browser. The Mermaid file is a text representation of the same nodes and edges.
The displayed output was captured from the supplied default program in a separate folder, with no project-level module on its import path. It is a reproducible demonstration on these examples, not a claim about arbitrary inputs.
Walk through what just happened
1. Create a durable checkpointer
SqliteSaver writes checkpoints to a SQLite file under .runtime. A checkpoint stores graph state and execution progress; it does not save your Python function definitions. The same code is available when the next process starts.
2. Identify the saved history
config contains a thread_id derived from --id. Reusing that ID with the same database file selects the pending review. This is an application identifier, not an operating-system thread.
3. Pause inside review
interrupt exposes the review payload and suspends the graph. The supplied initial approved=False value is not a reviewer’s rejection. Use the pending next node to understand whether a decision is still needed.
4. Resume with an explicit decision
Command(resume=...) supplies the result of the interrupt call. The node starts again on resume, so code before the interrupt must be safe to repeat. This lesson performs no email or payment operation there.
Change one thing yourself
Repeat the exercise with a new ID and use reject instead of approve. Compare approved and next. Use a fresh ID for another start; the program rejects accidentally starting an already existing review.
If your result is different
“No review found” usually means a different ID or working directory selected another SQLite file. Keep all three commands in the same lesson folder.
Check the complete imports and filenames first. Then compare your input and command with the shown run. Numeric results may differ slightly between platforms; the program's assertions check the intended relationships rather than relying on a decorative screenshot.
What you can now explain
Pause a review, exit Python, and resume the same saved workflow in a later process. Next we will recover from a temporary error, print progress, and protect a local queue against duplicates.
Technical reference: LangGraph Graph API. This project and its wording are original CodeTips Academy material.