Most agent demos keep their state in memory and act without asking anyone. That's fine for a notebook, but it isn't acceptable when the agent can send an email or submit something on a user's behalf.
When I built a multi-stage automation agent in Python, I needed two properties:
- Durability: if the process restarts halfway through a run, the run picks up where it left off.
- A human gate: before any step with an external side effect, the agent stops and waits for a person to approve.
LangGraph provides both, through checkpointers and interrupts. Here is the minimal version of the pattern.
The graph
Three nodes: draft something, wait for approval, then act on it.
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.types import interrupt, Command
class State(TypedDict):
task: str
draft: str
approved: bool
def draft_node(state: State) -> dict:
# call your LLM here; validate its output with Pydantic before trusting it
return {"draft": f"Proposed action for: {state['task']}"}
def approval_node(state: State) -> dict:
# pauses the run and hands the draft back to the caller
decision = interrupt({"draft": state["draft"], "question": "Approve?"})
return {"approved": decision == "approve"}
def act_node(state: State) -> dict:
if state["approved"]:
... # the only place with external side effects
return {}
builder = StateGraph(State)
builder.add_node("draft", draft_node)
builder.add_node("approval", approval_node)
builder.add_node("act", act_node)
builder.add_edge(START, "draft")
builder.add_edge("draft", "approval")
builder.add_edge("approval", "act")
builder.add_edge("act", END)
The rule that matters: side effects live in exactly one node, and that node is after the gate. Everything before the gate can safely run again.
Durable state with Postgres
Compile the graph with a Postgres checkpointer, and every step's state is saved under a thread_id:
from langgraph.checkpoint.postgres import PostgresSaver
DB_URI = "postgresql://user:pass@localhost:5432/agents"
with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
checkpointer.setup() # creates the checkpoint tables once
graph = builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "job-1234"}}
result = graph.invoke({"task": "apply to job 1234"}, config)
# the run stops at interrupt(); the result carries the interrupt payload
Now the process can die, and so can the whole machine. Calling the graph again with the same thread_id loads the latest checkpoint from Postgres.
Resuming after a human decides
The approval can come from anywhere: a web UI, a chat message, a CLI prompt. Resume the same thread with the decision:
graph.invoke(Command(resume="approve"), config)
The run continues from the interrupt() call. approval_node receives "approve" as the return value, and act_node runs.
What I learned running this for real
-
Use one
thread_idper business object (a job, a ticket, an order), not per process. Resuming is then just a lookup. - Validate LLM output before it reaches state. A Pydantic model on each draft stops malformed output from spreading through the graph.
- Add a verifier step for generated content. I score drafts against the source facts and reject anything unsupported before it ever reaches the human gate, so reviewers spend their time on judgement, not on catching invented details.
-
Track tokens per thread. Logging token usage against the
thread_idmakes the cost of each business object visible. - Keep nodes idempotent where you can. Checkpointing protects state, but a node that crashes partway can still re-run its side effects. Put the risky work after the gate and make it safe to repeat.
Why it matters
Durability and human gates turn an agent from a demo into something you can run in production. Postgres is usually already in the stack, so you get durable state with no new infrastructure, and the interrupt pattern means you decide where a human must sign off.
I'm Trevor Ogina, a senior backend and cloud engineer in Nairobi building Go, Spring Boot and Python systems, and the LLM agents on top of them. GitHub ยท LinkedIn
Top comments (0)