AI Integration

Intent Classification as a LangGraph Router — Handling "Add to Cart", "Checkout", and "Search" in the Same Agent

Apr 13, 2026 6 min read

One chat box, many possible actions: search, compare, add to cart, checkout. This article shows how ShopAgent uses a dedicated intent-classification node in LangGraph to route everything cleanly before the LLM ever responds. You’ll see how structured output, deterministic button short-circuits, and cart-aware context make the agent both cheaper and more reliable.

One user message, many possible actions. How ShopAgent classifies intent and routes to the right node — with structured output, deterministic short-circuits, and a clean separation between the LLM decision and the graph dispatch.

When a user types "show me running shoes under $100", the agent needs to search. When they type "add the Nike Pegasus to my cart", it needs to add an item. When they say "I'm ready to checkout", it needs to initiate a UCP session. Same input surface, completely different operations.

The naive approach is to let the LLM decide what to do as part of the response generation. The problem: the LLM's decision ends up interleaved with the response text, the conversation history fills with tool calls and tool messages, and classifying intent for a button click ("Add to Cart") costs the same LLM call as classifying a complex search query.

ShopAgent separates classification from response generation. A dedicated classify_intent node runs at the start of every message, writes next_action to state, and exits. LangGraph routes from there. The LLM response is generated only at the end, by the respond node, with no knowledge of the routing logic.

WHY A SEPARATE CLASSIFICATION NODE

 

Mixing intent classification with response generation creates several problems:

Tool call messages in history — If the LLM decides to call a search_products tool as part of its response, LangChain adds a tool_call message and then expects a tool result message to follow. Over multiple turns, the conversation history accumulates these pairs. If the tool message is missing (say, the node raised an exception), the next OpenAI API call returns a 400 error.

Cost — "Add to Cart" buttons send a structured prefix message. Routing them through the LLM costs a full API call for something that could be a two-line string check.

Observability — When routing and response generation are separate, you can log exactly what intent was classified and why, independently of what the LLM said in response.

The separate classify_intent node uses the OpenAI SDK directly — not LangChain's wrapper — precisely to avoid injecting tool messages into the conversation history.

STRUCTURED OUTPUT WITH PYDANTIC

 

The intent classification uses Pydantic models for structured output:

class IntentAction(BaseModel):
    intent: Literal[
        "search_products",
        "compare_products",
        "initiate_checkout",
        "provide_shipping",
        "complete_checkout",
        "get_order_history",
        "respond",
    ]
    query: Optional[str] = None
    category: Optional[str] = None
    max_price: Optional[float] = None

class ClassifyIntentResult(BaseModel):
    actions: list[IntentAction]

The _INTENT_DESCRIPTION string is the field description for intent. It explains to the model what each intent means and when to use it. This is where the prompt engineering lives for classification — not in a system prompt, but in the Pydantic field description.

_INTENT_DESCRIPTION = (
    "search_products: user wants to browse/search for products. "
    "compare_products: user wants to compare the products currently shown. "
    "initiate_checkout: user wants to start checkout or review their cart. "
    "provide_shipping: user is giving a delivery/shipping address. "
    "complete_checkout: user EXPLICITLY confirms the order. "
    "get_order_history: user asks about past orders. "
    "respond: general question, clarification, OR any request about "
    "adding/removing/changing cart items."
)

Notice that add_to_cart and update_cart are not in the intent list at all. Those are only reachable through the deterministic short-circuit below. This prevents the LLM from misclassifying "add this to my cart" as something that routes to the add_to_cart node — which would cause the orchestrator to add an item it does not actually know the product ID for.

THE OPENAI SDK DIRECTLY — BYPASSING LANGCHAIN'S EVENT PIPELINE

 

The classification call uses the OpenAI SDK's beta.chat.completions.parse() method:

_classify_client = AsyncOpenAI()

completion = await _classify_client.beta.chat.completions.parse(
    model="gpt-4o",
    messages=oai_messages,
    response_format=ClassifyIntentResult,
    temperature=0,
)
result = completion.choices[0].message.parsed

response_format=ClassifyIntentResult instructs the API to return valid JSON that matches the Pydantic schema. temperature=0 ensures deterministic classification — the same message should always produce the same intent.

The reason we use the SDK directly instead of LangChain's with_structured_output() is that LangChain injects the classification into the event stream. AG-UI captures all LangChain events and streams them to the frontend. A classification response would appear in the chat as an assistant message — the user would see the raw intent JSON before the actual response.

The SDK call is invisible to LangChain's event pipeline. Only the respond node's LLM call — which uses LangChain — is visible to AG-UI.

DETERMINISTIC SHORT-CIRCUITS FOR BUTTON ACTIONS

 

When a user clicks "Add to Cart" on a product card, the frontend sends a message with a structured prefix:

[CART_ADD] product_id=shoe-001 quantity=1

The classify_intent node checks for these prefixes before making any LLM call:

for m in reversed(messages):
    if getattr(m, "type", "") == "human":
        content = str(m.content or "")

        if content.startswith("[CART_ADD]"):
            body = content[len("[CART_ADD]"):].strip()
            fields = {}
            for part in body.split():
                if "=" in part:
                    k, v = part.split("=", 1)
                    fields[k.strip()] = v.strip()
            return {
                "next_action": "add_to_cart",
                "_pending_product_id": fields.get("product_id", ""),
                "_pending_quantity": int(fields.get("quantity", 1)),
            }

        if content.startswith("[CART_UPDATE]"):
            # Similar parsing for quantity updates and removals
            ...

        if content.startswith("[FORM]"):
            return {"next_action": "provide_shipping"}

        break

These actions never reach the LLM. They are routed deterministically. No API cost, no latency, no hallucination risk. A cart action triggered by a button is exactly as reliable as a function call.

MULTI-INTENT EXTRACTION

 

The ClassifyIntentResult model has a list[IntentAction] field. When a user sends a multi-part message — "search for trail shoes and compare them" — the LLM returns two actions:

{
    "actions": [
        {"intent": "search_products", "query": "trail shoes"},
        {"intent": "compare_products"}
    ]
}

The first action executes immediately. The rest are queued in _pending_intents. After search_products completes, route_after_action routes to process_next_intent, which pops compare_products and executes it. The user sees both results in one response turn.

first = result.actions[0]
remaining = [a.model_dump(exclude_none=True) for a in result.actions[1:3]]

updates = _intent_to_state_updates(first, state)
updates["_pending_intents"] = remaining
updates["_actions_taken"] = 0
return updates

The cap at three actions prevents adversarial prompts from queuing an indefinite chain.

CART CONTEXT INJECTION

 

One non-obvious requirement: the classification LLM needs to know what products are currently displayed and what is in the cart. Without this context, "add the Nike to my cart" cannot be resolved to a product ID.

Before the LLM call, the node injects this context into the system message:

if product_candidates_raw:
    products = [ProductCandidate(**p) for p in product_candidates_raw]
    prod_lines = ", ".join(
        f"{p.name} (ID: {p.product_id})" for p in products
    )
    extra_context += f"\n\nAvailable products: [{prod_lines}]"

if cart_raw:
    cart_lines = ", ".join(
        f"{it.name} (ID: {it.product_id}) x{it.quantity}"
        for it in cart_items
    )
    extra_context += f"\n\nCurrent cart: [{cart_lines}]"

This lets the classifier extract a product ID from a product name mentioned in the user's message. The extracted ID goes into _pending_product_id in state. The add_to_cart node reads it and looks up the full product.

WHAT WE'D DO DIFFERENTLY

 

Fuzzy name matching — The classifier extracts product IDs by passing names to the LLM. If a user types "the Nike shoes" and the product is "Nike Air Zoom Pegasus 41", the LLM should match them. This works well in practice but a dedicated fuzzy string matching step (as a fallback) would make it more robust.

Classification caching — The same user message at the same conversation state should produce the same classification. A short-lived cache keyed on the message content and the last few conversation turns would eliminate redundant API calls for identical inputs.

THE TAKEAWAY

 

Separating intent classification from response generation is one of the most important architectural decisions in ShopAgent. The classifier runs at temperature=0, uses structured output, and routes deterministically for button-driven actions. The responder runs at the end with full context. Each does its job cleanly.

The pattern is widely applicable: any agent that handles multiple distinct operations benefits from a dedicated classification stage that writes to state and exits, rather than trying to do routing and response in the same LLM call.

 

The ShopAgent demo is live at https://shop-agent.agilecreativeminds.nl. See the demo showcase or follow the demo walkthrough. Built by Agile Creative Minds.