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
SqliteSaverforPostgresSaverorRedisSaver(both inlanggraph-checkpoint). - Concurrency: Each
thread_idis a separate checkpoint thread. Run multiple graph instances across workers — the checkpointer handles locking. - Idempotency: The
sendnode should be idempotent (use a provider-side idempotency key derived fromcart_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 wait → compose → send 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.