n4nAI

Building a cart-recovery agent with LangGraph

Build a production-ready cart recovery agent with LangGraph — state machines, conditional routing, checkpointing, and real integration patterns.

n4n Team4 min read867 words

Audio narration

Coming soon — every post will get a voice note here.

A cart recovery agent langgraph implementation needs more than a prompt and a loop. You need durable state, explicit failure handling, and the ability to pause for human approval without losing context. LangGraph gives you a state machine with checkpointing built in, which maps cleanly to the recovery workflow: detect abandonment, score the cart, compose a message, send it, then wait for a conversion signal or timeout. This tutorial builds a complete, runnable agent you can extend into production.

Prerequisites

  • Python 3.11+
  • langgraph>=0.2.0, langchain-core>=0.3.0, langchain-openai>=0.2.0
  • An OpenAI API key (or any LLM provider with an OpenAI-compatible endpoint)
  • Redis or SQLite for checkpoint persistence (we’ll use SQLite for simplicity)
  • A cart events table or stream — we’ll mock the interface so you can swap in your real data layer
pip install langgraph langchain-core langchain-openai redis sqlalchemy

Set your API key:

export OPENAI_API_KEY="sk-..."

Define the state schema

Every LangGraph agent starts with a TypedDict that captures everything the graph needs to decide what happens next. For cart recovery, we track the cart identifier, the customer profile, the abandonment timestamp, a recovery score, the composed message, delivery status, and whether a human has intervened.

# state.py
from typing import TypedDict, Literal, Optional
from datetime import datetime
from pydantic import BaseModel, Field

class CartSnapshot(BaseModel):
    cart_id: str
    customer_id: str
    items: list[dict]  # [{sku, qty, price_cents}]
    subtotal_cents: int
    abandoned_at: datetime
    customer_email: str
    customer_name: str
    session_metadata: dict = Field(default_factory=dict)

class RecoveryState(TypedDict):
    cart: CartSnapshot
    score: float                    # 0.0 - 1.0, likelihood of recovery
    message: Optional[str]          # composed recovery message
    channel: Literal["email", "sms", "push"]
    sent_at: Optional[datetime]
    delivery_status: Optional[Literal["pending", "delivered", "failed"]]
    conversion: Optional[bool]      # True if customer completed purchase
    human_review: bool              # escalated for manual override
    retry_count: int
    error: Optional[str]

The score field drives conditional routing. The human_review flag lets you pause the graph for manual approval — a pattern that’s essential when compliance or brand voice requires a human in the loop.

Build the nodes

Each node is a pure function that receives the state and returns a partial update. Keep nodes small and side-effect-free where possible; push I/O to dedicated nodes so you can retry or replay them independently.

Node 1: Score the cart

# nodes/score.py
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from state import RecoveryState

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

SCORING_PROMPT = ChatPromptTemplate.from_template("""
You are a recovery scoring engine. Given a cart snapshot, output a single float 0.0-1.0
representing the probability this customer will recover with a single message.

Factors to weigh:
- Cart value (higher = more intent)
- Item categories (replenishable vs discretionary)
- Time since abandonment (decay curve)
- Customer history (repeat buyer vs new)
- Session signals (discount code attempted, shipping page viewed)

Cart: {cart_json}
Return ONLY the float.
""")

async def score_cart(state: RecoveryState) -> RecoveryState:
    cart = state["cart"]
    cart_json = cart.model_dump_json()
    response = await llm.ainvoke(SCORING_PROMPT.format_messages(cart_json=cart_json))
    try:
        score = float(response.content.strip())
        score = max(0.0, min(1.0, score))
    except ValueError:
        score = 0.3  # conservative default
    return {"score": score}

Expected output for a $120 cart abandoned 45 minutes ago by a repeat customer:

score: 0.72

Node 2: Compose the message

# nodes/compose.py
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from state import RecoveryState

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.3)

COMPOSE_PROMPT = ChatPromptTemplate.from_template("""
Write a cart recovery {channel} for {customer_name}.

Cart value: ${subtotal:.2f}
Items: {items_summary}
Abandoned: {minutes_ago} minutes ago
Recovery score: {score:.0%}

Guidelines:
- {channel}-appropriate length (email: 3-4 sentences, sms: 160 chars, push: 90 chars)
- No discount unless score < 0.4
- Urgency without pressure
- Clear CTA: "Complete your order"
- Brand voice: helpful, not desperate

Return ONLY the message text.
""")

def summarize_items(items: list[dict]) -> str:
    return ", ".join(f"{i['qty']}x {i['sku']}" for i in items[:3])

async def compose_message(state: RecoveryState) -> RecoveryState:
    cart = state["cart"]
    minutes_ago = (datetime.utcnow() - cart.abandoned_at).total_seconds() / 60
    prompt = COMPOSE_PROMPT.format_messages(
        channel=state["channel"],
        customer_name=cart.customer_name,
        subtotal=cart.subtotal_cents / 100,
        items_summary=summarize_items(cart.items),
        minutes_ago=int(minutes_ago),
        score=state["score"],
    )
    response = await llm.ainvoke(prompt)
    return {"message": response.content.strip()}

Expected output (email channel, score 0.72):

Hi Sarah, your cart with 2x SKU-A12, 1x SKU-B09 is waiting — $120.00 total. 
We've held these items for you. Complete your order here: [link]

Node 3: Send the message

This is where you integrate with your actual messaging provider (SendGrid, Twilio, Braze, etc.). The node returns delivery status so the graph can branch on failure.

# nodes/send.py
from state import RecoveryState
from datetime import datetime
import httpx

async def send_message(state: RecoveryState) -> RecoveryState:
    channel = state["channel"]
    message = state["message"]
    cart = state["cart"]

    # Replace with your real provider client
    async with httpx.AsyncClient(timeout=10.0) as client:
        if channel == "email":
            resp = await client.post(
                "https://api.your-esp.com/send",
                json={
                    "to": cart.customer_email,
                    "subject": "You left something behind",
                    "html": message,
                },
            )
        elif channel == "sms":
            resp = await client.post(
                "https://api.your-sms.com/send",
                json={"to": cart.customer_phone, "body": message},
            )
        else:  # push
            resp = await client.post(
                "https://api.your-push.com/send",
                json={"user_id": cart.customer_id, "body": message},
            )

    if resp.is_success:
        return {"sent_at": datetime.utcnow(), "delivery_status": "delivered"}
    else:
        return {"delivery_status": "failed", "error": resp.text}

Expected output on success:

sent_at: 2024-01-15T14:32:11.234Z
delivery_status: "delivered"

Node 4: Wait for conversion (with timeout)

This node demonstrates LangGraph’s interrupt capability. In production, you’d register a webhook that resumes the graph when a purchase event arrives. For the tutorial, we simulate with a configurable delay and a random outcome.

# nodes/wait.py
from state import RecoveryState
from langgraph.types import interrupt
from datetime import datetime, timedelta
import asyncio
import random

async def wait_for_conversion(state: RecoveryState) -> RecoveryState:
    # In production: interrupt() pauses the graph until an external signal resumes it.
    # The interrupt payload is passed to the resume call.
    conversion_signal = interrupt({
        "cart_id": state["cart"].cart_id,
        "wait_until": (datetime.utcnow() + timedelta(hours=24)).isoformat(),
    })

    # When resumed, conversion_signal contains the webhook payload
    converted = conversion_signal.get("converted", False)
    return {"conversion": converted, "sent_at": datetime.utcnow()}

To resume from your webhook handler:

# webhook_handler.py (FastAPI example)
from fastapi import FastAPI, Request
from langgraph.checkpoint.sqlite import SqliteSaver
from graph import build_graph

app = FastAPI()
checkpointer = SqliteSaver.from_conn_string("sqlite:///checkpoints.db")
graph = build_graph(checkpointer=checkpointer)

@app.post("/webhook/purchase")
async def purchase_webhook(request: Request):
    payload = await request.json()
    cart_id = payload["cart_id"]
    # Find the thread_id associated with this cart (store mapping in Redis)
    thread_id = await redis.get(f"cart_thread:{cart_id}")
    if thread_id:
        await graph.ainvoke(None, config={"configurable": {"thread_id": thread_id}}, input={
            "converted": True,
            "order_id": payload["order_id"],
        })
    return {"status": "resumed"}

Node 5: Escalate to human review

Low-score carts or repeated failures route here. The interrupt pauses execution until a reviewer approves, edits, or cancels the message.

# nodes/escalate.py
from state import RecoveryState
from langgraph.types import interrupt

async def human_review(state: RecoveryState) -> RecoveryState:
    review = interrupt({
        "cart_id": state["cart"].cart_id,
        "customer": state["cart"].customer_name,
        "score": state["score"],
        "draft_message": state["message"],
        "channel": state["channel"],
        "reason": "score_below_threshold" if state["score"] < 0.35 else "delivery_failed",
    })
    # review contains: {"action": "approve" | "edit" | "cancel", "message": "..."}
    if review["action"] == "approve":
        return {"human_review": False, "message": review.get("message", state["message"])}
    elif review["action"] == "edit":
        return {"human_review": False, "message": review["message"]}
    else:
        return {"human_review": False, "error": "cancelled_by_reviewer"}

Wire the graph with conditional edges

Now compose the nodes into a StateGraph. The routing logic lives in edge functions — pure predicates that read state and return the next node name.

# graph.py
from langgraph.graph import StateGraph, END
from langgraph.checkpoint.sqlite import SqliteSaver
from state import RecoveryState
from nodes.score import score_cart
from nodes.compose import compose_message
from nodes.send import send_message
from nodes.wait import wait_for_conversion
from nodes.escalate import human_review

def should_escalate(state: RecoveryState) -> str:
    if state["score"] < 0.35:
        return "escalate"
    if state["delivery_status"] == "failed" and state["retry_count"] < 2:
        return "retry_send"
    return "wait"

def after_wait(state: RecoveryState) -> str:
    if state.get("conversion"):
        return "converted"
    if state["retry_count"] >= 2:
        return "max_retries"
    return "escalate"  # no conversion after 24h → human review

def build_graph(checkpointer=None):
    workflow = StateGraph(RecoveryState)

    workflow.add_node("score", score_cart)
    workflow.add_node("compose", compose_message)
    workflow.add_node("send", send_message)
    workflow.add_node("wait", wait_for_conversion)
    workflow.add_node("escalate", human_review)

    workflow.set_entry_point("score")
    workflow.add_edge("score", "compose")
    workflow.add_edge("compose", "send")
    workflow.add_conditional_edges("send", should_escalate, {
        "escalate": "escalate",
        "retry_send": "send",
        "wait": "wait",
    })
    workflow.add_conditional_edges("wait", after_wait, {
        "converted": END,
        "max_retries": END,
        "escalate": "escalate",
    })
    workflow.add_edge("escalate", "send")  # after approval, re-send

    return workflow.compile(checkpointer=checkpointer)

The checkpointer is what makes this durable. With SqliteSaver, every step writes a checkpoint. If the process crashes mid-wait, you resume exactly where you left off — no duplicate sends, no lost state.

Run the agent end-to-end

# run.py
import asyncio
from datetime import datetime, timedelta
from graph import build_graph
from state import CartSnapshot, RecoveryState
from langgraph.checkpoint.sqlite import SqliteSaver

async def main():
    checkpointer = SqliteSaver.from_conn_string("sqlite:///checkpoints.db")
    app = build_graph(checkpointer=checkpointer)

    cart = CartSnapshot(
        cart_id="cart_7x9k2",
        customer_id="cust_3m8n",
        items=[
            {"sku": "SKU-A12", "qty": 2, "price_cents": 4500},
            {"sku": "SKU-B09", "qty": 1, "price_cents": 3000},
        ],
        subtotal_cents=12000,
        abandoned_at=datetime.utcnow() - timedelta(minutes=45),
        customer_email="sarah@example.com",
        customer_name="Sarah",
        session_metadata={"discount_attempted": True, "viewed_shipping": True},
    )

    initial_state: RecoveryState = {
        "cart": cart,
        "score": 0.0,
        "message": None,
        "channel": "email",
        "sent_at": None,
        "delivery_status": None,
        "conversion": None,
        "human_review": False,
        "retry_count": 0,
        "error": None,
    }

    # thread_id ties this run to a checkpoint thread
    config = {"configurable": {"thread_id": f"recovery_{cart.cart_id}"}}

    # First invocation runs until the first interrupt (wait_for_conversion)
    result = await app.ainvoke(initial_state, config=config)
    print("After first run:", result.keys())
    # Output: dict_keys(['cart', 'score', 'message', 'channel', 'sent_at', 
    #                    'delivery_status', 'conversion', 'human_review', 
    #                    'retry_count', 'error', '__interrupt__'])

    # The graph paused at wait_for_conversion. Inspect the interrupt payload:
    interrupt_info = result.get("__interrupt__")
    if interrupt_info:
        print("Waiting for conversion signal:", interrupt_info[0].value)
        # In reality, your webhook calls app.ainvoke(None, config=config, input={...})
        # For demo, simulate a conversion after 10 seconds:
        await asyncio.sleep(10)
        result = await app.ainvoke(
            {"converted": True, "order_id": "ord_9921"},
            config=config,
        )
        print("Final state:", result["conversion"], result.get("error"))

if __name__ == "__main__":
    asyncio.run(main())

Run it:

python run.py

Expected console output:

After first run: dict_keys(['cart', 'score', 'message', 'channel', 'sent_at', 'delivery_status', 'conversion', 'human_review', 'retry_count', 'error', '__interrupt__'])
Waiting for conversion signal: {'cart_id': 'cart_7x9k2', 'wait_until': '2024-01-16T14:32:11.234Z'}
Final state: True None

The __interrupt__ key in the result is how LangGraph signals that the graph paused. Your webhook handler resumes by invoking the same thread with the conversion payload.

Observability: log every transition

Production agents need an audit trail. Add a simple callback that writes each state transition to your logging backend.

# observability.py
from langgraph.graph import StateGraph
from state import RecoveryState
import json
import logging

logger = logging.getLogger("cart_recovery")
logger.setLevel(logging.INFO)

def log_transitions(app):
    original_invoke = app.ainvoke

    async def logged_invoke(input, config, **kwargs):
        result = await original_invoke(input, config, **kwargs)
        thread_id = config["configurable"]["thread_id"]
        logger.info(json.dumps({
            "thread_id": thread_id,
            "cart_id": result.get("cart", {}).get("cart_id"),
            "score": result.get("score"),
            "channel": result.get("channel"),
            "delivery_status": result.get("delivery_status"),
            "conversion": result.get("conversion"),
            "human_review": result.get("human_review"),
            "error": result.get("error"),
        }))
        return result

    app.ainvoke = logged_invoke
    return app

Wrap your compiled graph:

app = log_transitions(build_graph(checkpointer=checkpointer))

Testing the graph in isolation

LangGraph graphs are just functions — you can unit-test each node and the routing logic without spinning up a checkpointer.

# test_graph.py
import pytest
from graph import build_graph
from state import RecoveryState, CartSnapshot
from datetime import datetime, timedelta

@pytest.fixture
def sample_state():
    cart = CartSnapshot(
        cart_id="test_cart",
        customer_id="test_cust",
        items=[{"sku": "TEST", "qty": 1, "price_cents": 5000}],
        subtotal_cents=5000,
        abandoned_at=datetime.utcnow() - timedelta(hours=1),
        customer_email="test@test.com",
        customer_name="Test User",
    )
    return {
        "cart": cart,
        "score": 0.8,
        "message": "Test message",
        "channel": "email",
        "sent_at": None,
        "delivery_status": "delivered",
        "conversion": None,
        "human_review": False,
        "retry_count": 0,
        "error": None,
    }

def test_should_escalate_low_score(sample_state):
    from graph import should_escalate
    sample_state["score"] = 0.2
    assert should_escalate(sample_state) == "escalate"

def test_should_wait_high_score(sample_state):
    from graph import should_escalate
    sample_state["score"] = 0.8
    sample_state["delivery_status"] = "delivered"
    assert should_escalate(sample_state) == "wait"

def test_after_wait_converted(sample_state):
    from graph import after_wait
    sample_state["conversion"] = True
    assert after_wait(sample_state) == "converted"

Run with pytest test_graph.py -v.

Deployment notes

  • Checkpoint storage: SQLite works for development and low volume. For production, swap SqliteSaver for PostgresSaver or RedisSaver (both in langgraph-checkpoint).
  • Concurrency: Each thread_id is a separate checkpoint thread. Run multiple graph instances across workers — the checkpointer handles locking.
  • Idempotency: The send node should be idempotent (use a provider-side idempotency key derived from cart_id + retry_count).
  • Rate limits: Wrap the LLM calls in a semaphore or use a gateway that enforces per-model quotas and falls back across providers. If you’re routing through a gateway like n4n.ai, you get automatic fallback when a provider is rate-limited or degraded, plus per-token usage metering without extra instrumentation.
  • Secrets: Never bake API keys into the graph. Inject clients at node construction time via dependency injection or a context var.

Extending the workflow

Common additions you’ll need:

Requirement Where to add it
A/B test message variants Branch in compose node, store variant in state
Dynamic channel selection (email vs SMS vs push) Add a select_channel node after scoring
Discount code generation New node between compose and send for low-score carts
Multi-touch sequence (day 1, day 3, day 7) Loop waitcomposesend with retry_count gating
GDPR/CCPA opt-out check Guard node at entry point, read from consent store

The graph structure stays the same; you only add nodes and adjust edges.

Summary

You now have a cart recovery agent langgraph implementation that:

  • Scores carts with an LLM and routes on the result
  • Composes channel-appropriate messages
  • Sends via your real providers with delivery confirmation
  • Pauses durably for conversion signals using interrupt()
  • Escalates to human review when automation isn’t enough
  • Checkpoints every step so crashes don’t lose progress or duplicate sends

The full runnable code is ~200 lines across six files. Drop it into your repo, wire your actual messaging APIs and webhook endpoint, and you have a production-grade recovery flow that your team can extend without rewriting the orchestration layer.

Tagslanggraphecommercecart-recoveryagents

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All framework tutorials: e-commerce search & recommendations posts →