DEV Community

Trevor Ogina
Trevor Ogina

Posted on AI-assisted

Durable AI agents: LangGraph with Postgres checkpointing and a human approval gate

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:

  1. Durability: if the process restarts halfway through a run, the run picks up where it left off.
  2. 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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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_id per 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_id makes 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)