Code Intermediate medium · 7 min

Building an approval workflow

What you will learn
Chain multiple decision nodes with conditional routing to create a multi-step approval process.

Why this matters

Real production systems rarely execute in a single pass: approvals, escalations, and manual reviews are standard requirements. LangGraph's conditional edges let you build these workflows without losing context or restarting computation.

Skip if: Don't use conditional routing for simple linear pipelines where every step always runs. Don't use StateGraph for stateless request-response APIs: use LangChain's LCEL directly. Don't implement approval workflows in OpenAI function calling alone if you need persistent state or multi-turn human decisions.

Explanation

An approval workflow in LangGraph is a graph where nodes represent decision or processing steps, and conditional edges route execution based on the current state. Instead of hard-coded if/else logic, you define edge conditions as functions that inspect the state and return the next node name. This decouples routing logic from node logic and makes the flow auditable: you can visualize exactly which path was taken and why. Mechanically: you create a StateGraph, add nodes for each approval stage (draft → review → approve/reject → notify), then use add_conditional_edges() to branch execution based on fields like state["approval_status"]. The graph executes node by node, updating shared state, until it reaches an END node. When to use: Any process with human decisions, escalations, or multi-stage reviews: content moderation, expense approval, document signing, or AI agent loops with human feedback.

Analogy

Think of it like a physical mail room workflow: a document arrives at Draft (inbox node), gets routed to the Review desk (conditional edge checks sender). If approved, it goes to Notify (notification node). If rejected, back to Draft. The routing logic (which desk to send it to) is separate from the work (reviewing), so you can change routing rules without touching the review process itself.

Code

python
from langgraph.graph import StateGraph, START, END
from typing_extensions import TypedDict
from langgraph.checkpoint.memory import MemorySaver

class ApprovalState(TypedDict):
    document: str
    approval_count: int
    rejection_reason: str | None
    status: str

def draft_node(state: ApprovalState) -> ApprovalState:
    return {
        **state,
        "document": f"Document v{state['approval_count']}: {state['document']}",
        "status": "drafted"
    }

def review_node(state: ApprovalState) -> ApprovalState:
    return {
        **state,
        "approval_count": state["approval_count"] + 1,
        "status": "under_review"
    }

def approve_node(state: ApprovalState) -> ApprovalState:
    return {
        **state,
        "rejection_reason": None,
        "status": "approved"
    }

def reject_node(state: ApprovalState) -> ApprovalState:
    return {
        **state,
        "rejection_reason": "Failed quality check",
        "status": "rejected"
    }

def route_after_review(state: ApprovalState) -> str:
    if state["approval_count"] >= 3:
        return "approve"
    elif state["approval_count"] == 2:
        return "reject"
    else:
        return "review"

workflow = StateGraph(ApprovalState)
workflow.add_node("draft", draft_node)
workflow.add_node("review", review_node)
workflow.add_node("approve", approve_node)
workflow.add_node("reject", reject_node)

workflow.add_edge(START, "draft")
workflow.add_edge("draft", "review")
workflow.add_conditional_edges(
    "review",
    route_after_review,
    {
        "review": "review",
        "approve": "approve",
        "reject": "reject"
    }
)
workflow.add_edge("approve", END)
workflow.add_edge("reject", END)

checkpointer = MemorySaver()
graph = workflow.compile(checkpointer=checkpointer)

initial_state = {
    "document": "Expense report",
    "approval_count": 0,
    "rejection_reason": None,
    "status": "draft"
}

result = graph.invoke(
    initial_state,
    config={"configurable": {"thread_id": "approval-1"}}
)

print(f"Final Status: {result['status']}")
print(f"Approvals: {result['approval_count']}")
print(f"Document: {result['document']}")
if result['rejection_reason']:
    print(f"Reason: {result['rejection_reason']}")
Output
Final Status: rejected
Approvals: 2
Document: Document v2: Expense report
Reason: Failed quality check

What just happened?

The graph started at the draft node, which created a versioned document and moved status to 'drafted'. It then entered the review loop three times: first review incremented approval_count to 1 and routed back to review (because < 2), second review set approval_count to 2 and routed to reject (because == 2), reject node added a rejection reason and set status to 'rejected', then the graph hit END. The state was threaded through each node, accumulating changes without mutation.

Common gotcha

Developers often forget that add_conditional_edges() requires a mapping dictionary: passing only the function isn't enough. If you return a string like `"review"` from your routing function but don't map it in the third argument, LangGraph will raise a `ValueError` saying the target node doesn't exist. Also, routing functions must return exactly one string node name, not a list or None.

Error recovery

ValueError: Target node does not exist
Your conditional function returned a node name not in the graph. Check the mapping dict in add_conditional_edges() matches all possible return values from your routing function.
KeyError in route function
Your routing function is accessing state keys that don't exist yet. Verify the StateDict includes all keys and that upstream nodes initialize them before the conditional is evaluated.
TypeError: object is not iterable
You passed a single node name to add_edge() instead of calling add_conditional_edges(). Conditional routing requires the edge to be conditional; linear edges use add_edge().

Experienced dev note

Beginners often hardcode approval logic into the node function itself (if/else inside the function), which makes testing and visualization impossible. Instead, keep nodes as pure transformers: they only transform state: and move all routing decisions into the edge functions. This separates concerns and lets you visualize the workflow graph directly. Also: always use MemorySaver() in development to inspect state at each step via graph.get_state(): it saves hours of debugging.

Check your understanding

If the approval_count reaches 5 instead of 3, and you want approvals to succeed only after 5 reviews, where would you change the code and why wouldn't changing only the reject_node fix it?

Show answer hint

The number 3 in route_after_review is the decision logic, not the approval logic. The node just transforms state; routing controls flow. You must change the condition in route_after_review() because that's where the branching decision happens. The reject_node doesn't know about the approval threshold.

VERSION langgraph >= 0.2.0 uses StateGraph with named nodes; do not use the deprecated MessageGraph or string 'START'/'END' from earlier 0.1.x versions. Always import START and END from langgraph.graph.
NEXT

Learn how to add human-in-the-loop checkpoints so your approval workflow can pause and wait for manual approval before continuing execution.

Community Notes

No notes yetBe the first to share a version-specific fix or tip.