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.

AI chatbot interface handling a multi-step cleaning service booking conversation with state management IT Technology Image

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 checkpointer is 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.

Python code editor showing LangGraph state schema and node definitions for a booking agent Developer Related Image

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 TypedDict grows. 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. MemorySaver is 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

  1. Add a real UI — Streamlit or a lightweight React frontend to replace the CLI.
  2. Persist state in Postgres — swap MemorySaver for a production checkpointer.
  3. Integrate calendars — connect to Google Calendar or Calendly for real slot proposals.
  4. Add human-in-the-loop — for edge cases (e.g., unusual service requests), escalate to a human via a LangGraph interrupt.
  5. Evaluate with Langfuse datasets — build a regression suite of sample conversations to catch prompt regressions.

Langfuse observability dashboard displaying token usage and trace logs for an AI agent Programming Illustration

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:

Source: Towards Data Science — Replacing a 15-Minute Booking Process with a LangGraph AI Agent

This content was drafted using AI tools based on reliable sources, and has been reviewed by our editorial team before publication. It is not intended to replace professional advice.