Why a Stateful Agent Beats a Simple Chatbot
Imagine a customer reaching out to a cleaning company. They ask about couch size, material, send a photo, share an address, then inquire about apartment cleaning, negotiate price, and finally pick a time slot. That back-and-forth takes about 15 minutes — and it's exactly the kind of repetitive, multi-step workflow that begs for automation.
But a naive chatbot won't cut it. You need an agent that remembers context across messages, extracts structured data, applies deterministic business rules (pricing), and orchestrates the next step. That's where LangGraph shines: it gives you a stateful graph where nodes read and update a shared state, and conditional edges decide what runs next.
In this tutorial, we'll build a customer-service agent that:
- Understands natural language queries and extracts booking details.
- Calculates a price from structured data.
- Handles acceptance/rejection.
- Proposes time slots and confirms the appointment.
We'll also wire up Langfuse for full observability — because if you can't see what your agent is doing in production, you're flying blind. For a broader look at how on-device function calling is evolving across platforms, check out our analysis of Google AI Edge's cross-platform function calling.
The full source code is available on GitHub — clone it, run it, and extend it.

Core Implementation: State, Nodes, and Conditional Routing
The heart of the agent is a TypedDict state that flows through the graph. Each node returns a partial update, and routing functions inspect the state to decide the next node.
from typing import TypedDict, Optional, List
from langgraph.graph import StateGraph, END
class BookingDetails(TypedDict, total=False):
service_type: str # e.g., "couch", "apartment"
size: Optional[str]
material: Optional[str]
address: Optional[str]
preferred_days: Optional[List[str]]
price: Optional[float]
confirmed: Optional[bool]
class AgentState(TypedDict):
messages: List[dict]
booking: BookingDetails
next_action: Optional[str]
# --- Node: extract details from conversation ---
def extract_details(state: AgentState) -> dict:
# In practice, call an LLM with structured output here
# For demo, we simulate extraction
last_msg = state["messages"][-1]["content"].lower()
booking = state.get("booking", {})
if "couch" in last_msg:
booking["service_type"] = "couch"
if "apartment" in last_msg:
booking["service_type"] = "apartment"
return {"booking": booking}
# --- Node: calculate price from structured details ---
def calculate_price(state: AgentState) -> dict:
booking = state["booking"]
base = 50.0 if booking.get("service_type") == "couch" else 120.0
if booking.get("material") == "leather":
base *= 1.2
return {"booking": {**booking, "price": base}}
# --- Routing: do we have enough info to price? ---
def route_after_extract(state: AgentState) -> str:
b = state["booking"]
required = ["service_type", "address"]
if all(b.get(k) for k in required):
return "calculate_price"
return END # wait for next customer message
# --- Build the graph ---
builder = StateGraph(AgentState)
builder.add_node("extract_details", extract_details)
builder.add_node("calculate_price", calculate_price)
builder.set_entry_point("extract_details")
builder.add_conditional_edges("extract_details", route_after_extract, {
"calculate_price": "calculate_price",
END: END,
})
builder.add_edge("calculate_price", END)
# Compile with a checkpointer for persistence across turns
from langgraph.checkpoint.memory import MemorySaver
graph = builder.compile(checkpointer=MemorySaver())
Key points:
- The
checkpointeris what makes the agent stateful across separate customer messages — critical for a booking flow that spans minutes or hours. - The routing function is where you encode business logic: "only ask a new question if something is missing."
- Nodes are pure functions of state → partial state, making them easy to test and reason about.
For a deeper dive into modern web platform features that pair well with these agent backends, see our roundup of CSS Quake, Gap Decorations, and the future of web platform features.

Observability with Langfuse (And What to Watch Out For)
Once your agent is running, the question shifts from "does it work?" to "what is it actually doing?". Langfuse is an open-source LLM engineering platform for tracing, monitoring, and evaluating agents. The free tier is plenty for a prototype.
import os
from langfuse.callback import CallbackHandler
# Set these in your .env file:
# LANGFUSE_PUBLIC_KEY=pk-lf-...
# LANGFUSE_SECRET_KEY=sk-lf-...
# LANGFUSE_HOST=https://cloud.langfuse.com
langfuse_handler = CallbackHandler(
public_key=os.getenv("LANGFUSE_PUBLIC_KEY"),
secret_key=os.getenv("LANGFUSE_SECRET_KEY"),
host=os.getenv("LANGFUSE_HOST"),
)
# Attach to any LangChain/LangGraph invocation
result = graph.invoke(
{"messages": [{"role": "user", "content": "I need a couch cleaning, leather, 3-seater, at 123 Main St."}]},
config={"callbacks": [langfuse_handler], "configurable": {"thread_id": "customer-42"}},
)
In the Langfuse dashboard you'll see every prompt, input, output, token count, and cost per trace. That last one matters more than people admit — a runaway agent can quietly burn through your API budget.
⚠️ Limitations and Gotchas
- Structured extraction is brittle. LLMs occasionally miss fields or hallucinate them. Always validate with Pydantic schemas before feeding into pricing logic.
- State schema drift. As you add features (e.g., recurring appointments), your
TypedDictgrows. Version it or migrate carefully — old threads with old schemas will break. - Deterministic pricing ≠ deterministic agent. Keep the pricing function pure and separate from the LLM. Never let the model "invent" a price.
- Checkpointer choice matters.
MemorySaveris fine for demos, but production needs a persistent store (Redis, Postgres) so state survives restarts. - Cost tracking is not optional. Without Langfuse (or equivalent), you'll discover your token bill the hard way.
🧭 Next Steps
- Add a real UI — Streamlit or a lightweight React frontend to replace the CLI.
- Persist state in Postgres — swap
MemorySaverfor a production checkpointer. - Integrate calendars — connect to Google Calendar or Calendly for real slot proposals.
- Add human-in-the-loop — for edge cases (e.g., unusual service requests), escalate to a human via a LangGraph interrupt.
- Evaluate with Langfuse datasets — build a regression suite of sample conversations to catch prompt regressions.

Wrapping Up
You've now seen how to build a stateful, multi-step booking agent that goes far beyond a simple chatbot. The combination of LangGraph's graph-based state management and Langfuse's observability gives you a foundation you can actually ship — not just demo.
The core idea is simple: let the LLM handle language, let deterministic code handle business rules, and let the graph handle orchestration. That separation is what makes agents reliable enough for production.
Start with the CLI demo, instrument it with Langfuse, then iterate toward a real UI and persistent storage. The 15-minute booking call doesn't stand a chance.
Further reading:
- Google AI Edge: On-Device Function Calling Goes Cross-Platform
- CSS Quake, Gap Decorations, and the Future of Web Platform Features
Source: Towards Data Science — Replacing a 15-Minute Booking Process with a LangGraph AI Agent