How to build a product recommendation chatbot with tool use

By Ingrid Haugen · 17 July 202630 views
How to build a product recommendation chatbot with tool use

A search box returns documents. A recommendation chatbot returns answers. The difference matters: "show me hiking boots under ₹5000 that work for monsoon season and narrow feet" is trivially easy to ask but frustrating to search. A chatbot that understands the query, queries your inventory, and explains trade-offs between options is genuinely useful in a way that even a great search UX is not.

Claude's tool use feature makes this buildable without a custom ML pipeline. You define tools that the model can call (product search, filter by spec, get product details), and Claude orchestrates the calls based on what the user asks. You handle the actual data queries; Claude handles the conversation and reasoning.

Defining the tools

Your tools are the bridge between the LLM and your product database. Define them precisely — vague tool descriptions lead to imprecise calls:

from anthropic import Anthropic
from typing import Any
import json

client = Anthropic()

RECOMMENDATION_TOOLS = [
    {
        "name": "search_products",
        "description": (
            "Search the product catalog by text query. Returns products ranked "
            "by relevance. Use this first to find candidate products before filtering."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "Search terms describing what the user wants",
                },
                "category": {
                    "type": "string",
                    "description": "Product category to search within (optional)",
                    "enum": ["footwear", "clothing", "accessories", "equipment"],
                },
                "limit": {
                    "type": "integer",
                    "description": "Maximum number of results to return (default 10, max 20)",
                    "default": 10,
                },
            },
            "required": ["query"],
        },
    },
    {
        "name": "filter_products",
        "description": (
            "Filter a list of product IDs by specific attributes like price range, "
            "size availability, or technical specifications."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "product_ids": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "List of product IDs to filter",
                },
                "max_price": {"type": "number", "description": "Maximum price in INR"},
                "min_price": {"type": "number", "description": "Minimum price in INR"},
                "sizes_available": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "Required sizes (e.g. ['8', '9', '10'] for shoes)",
                },
                "attributes": {
                    "type": "object",
                    "description": "Key-value pairs of product attributes to filter by",
                    "additionalProperties": {"type": "string"},
                },
            },
            "required": ["product_ids"],
        },
    },
    {
        "name": "get_product_details",
        "description": "Get full details for one or more products by ID.",
        "input_schema": {
            "type": "object",
            "properties": {
                "product_ids": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "Product IDs to fetch details for",
                },
            },
            "required": ["product_ids"],
        },
    },
    {
        "name": "check_stock",
        "description": "Check real-time stock availability for specific product+size combinations.",
        "input_schema": {
            "type": "object",
            "properties": {
                "items": {
                    "type": "array",
                    "items": {
                        "type": "object",
                        "properties": {
                            "product_id": {"type": "string"},
                            "size": {"type": "string"},
                        },
                        "required": ["product_id"],
                    },
                },
            },
            "required": ["items"],
        },
    },
]

Implementing the tool handlers

Each tool needs a handler function that queries your actual data source:

import random  # Simulated data — replace with real DB queries

class ProductCatalog:
    """
    Simulated product catalog. Replace with your actual database queries.
    """

    def search(self, query: str, category: str = None, limit: int = 10) -> list[dict]:
        # Replace with: elasticsearch query, Firestore full-text search, etc.
        products = [
            {
                "id": f"prod-{i}",
                "name": f"Product matching '{query}' #{i}",
                "category": category or "footwear",
                "price": random.randint(1000, 10000),
                "rating": round(random.uniform(3.5, 5.0), 1),
                "attributes": {
                    "waterproof": random.choice(["yes", "no"]),
                    "width": random.choice(["narrow", "standard", "wide"]),
                },
            }
            for i in range(1, min(limit + 1, 6))
        ]
        return products

    def filter(
        self,
        product_ids: list[str],
        max_price: float = None,
        min_price: float = None,
        sizes_available: list[str] = None,
        attributes: dict = None,
    ) -> list[str]:
        # Simulate filtering — replace with DB query
        return product_ids[:3]

    def get_details(self, product_ids: list[str]) -> list[dict]:
        return [
            {
                "id": pid,
                "name": f"Product {pid}",
                "price": 3499,
                "description": "High-quality product with excellent reviews.",
                "specs": {"material": "synthetic", "sole": "rubber"},
                "rating": 4.3,
                "review_count": 127,
            }
            for pid in product_ids
        ]

    def check_stock(self, items: list[dict]) -> list[dict]:
        return [
            {**item, "in_stock": random.choice([True, True, False])}
            for item in items
        ]


catalog = ProductCatalog()

def execute_tool(tool_name: str, tool_input: dict) -> Any:
    """Route tool calls to their implementations."""
    if tool_name == "search_products":
        return catalog.search(
            tool_input["query"],
            tool_input.get("category"),
            tool_input.get("limit", 10),
        )
    elif tool_name == "filter_products":
        return catalog.filter(
            tool_input["product_ids"],
            tool_input.get("max_price"),
            tool_input.get("min_price"),
            tool_input.get("sizes_available"),
            tool_input.get("attributes"),
        )
    elif tool_name == "get_product_details":
        return catalog.get_details(tool_input["product_ids"])
    elif tool_name == "check_stock":
        return catalog.check_stock(tool_input["items"])
    else:
        return {"error": f"Unknown tool: {tool_name}"}

The agentic loop

The core of the chatbot is an agentic loop that keeps calling the model until it produces a final text response (no more tool calls):

def run_recommendation_turn(
    user_message: str,
    conversation_history: list[dict],
    max_tool_rounds: int = 5,
) -> tuple[str, list[dict]]:
    """
    Process one user turn, potentially executing multiple tool rounds.
    Returns (assistant_response, updated_history).
    """
    history = conversation_history.copy()
    history.append({"role": "user", "content": user_message})

    for round_num in range(max_tool_rounds):
        response = client.messages.create(
            model="claude-opus-4-5",
            max_tokens=2048,
            system="""You are a helpful product recommendation assistant.

When a user asks for product recommendations:
1. Use search_products to find candidates matching their description
2. Use filter_products to narrow by price, size, or specific attributes
3. Use get_product_details to get full information on the top candidates
4. Use check_stock to verify availability before recommending
5. Present 2-3 specific recommendations with clear reasoning

Always explain WHY each product suits the user's specific requirements.
If nothing in stock matches well, say so honestly rather than recommending poor fits.""",
            tools=RECOMMENDATION_TOOLS,
            messages=history,
        )

        # Collect tool calls and text blocks
        tool_calls = [b for b in response.content if b.type == "tool_use"]
        text_blocks = [b for b in response.content if b.type == "text"]

        if not tool_calls:
            # No more tool calls — final response
            final_text = text_blocks[0].text if text_blocks else ""
            history.append({"role": "assistant", "content": final_text})
            return final_text, history

        # Execute all tool calls in this round
        history.append({"role": "assistant", "content": response.content})

        tool_results = []
        for tool_call in tool_calls:
            result = execute_tool(tool_call.name, tool_call.input)
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": tool_call.id,
                "content": json.dumps(result),
            })

        history.append({"role": "user", "content": tool_results})

    # Exceeded max rounds — force a response
    response = client.messages.create(
        model="claude-opus-4-5",
        max_tokens=512,
        messages=history + [{
            "role": "user",
            "content": "Please summarise what you found so far and give your best recommendation."
        }],
    )
    final_text = response.content[0].text
    history.append({"role": "assistant", "content": final_text})
    return final_text, history

Managing conversation state

For a chatbot, the conversation history is the state. In a web application, persist it per session:

class RecommendationSession:
    def __init__(self, session_id: str):
        self.session_id = session_id
        self.history: list[dict] = []
        self.context: dict = {}  # User preferences learned over the session

    def chat(self, user_message: str) -> str:
        response, self.history = run_recommendation_turn(
            user_message, self.history
        )
        return response

    def reset(self):
        self.history = []
        self.context = {}

Keep conversation history in Redis or Firestore for production — in-memory sessions do not survive server restarts. Set a TTL on sessions (30–60 minutes of inactivity) so stale history does not bloat storage or the context window.

Handling tool errors gracefully

Real product databases fail. Network calls time out, inventory services return 503, search indices go stale. The tool executor needs to return structured errors that the model can reason about rather than exceptions that crash the loop:

import functools
import traceback

def safe_execute_tool(tool_name: str, tool_input: dict) -> dict:
    """
    Wraps execute_tool to catch exceptions and return structured errors.
    The model receives the error description and can adjust its strategy.
    """
    try:
        result = execute_tool(tool_name, tool_input)
        return {"success": True, "data": result}
    except TimeoutError:
        return {
            "success": False,
            "error": "timeout",
            "message": f"The {tool_name} call timed out. Try with a smaller result set.",
        }
    except Exception as e:
        return {
            "success": False,
            "error": type(e).__name__,
            "message": str(e),
        }

When you return a structured error, Claude will usually retry with a modified input (fewer product IDs, a narrower query) or tell the user that the data source is temporarily unavailable. If you raise an exception instead, the loop crashes and the user sees a generic error with no explanation. Structured errors also make logs much easier to parse — you can grep for "success": false to find every failed tool call across all sessions.

You can also add a circuit breaker per tool so a repeatedly failing service stops being called mid-conversation:

from collections import defaultdict
from datetime import datetime, timedelta

class ToolCircuitBreaker:
    def __init__(self, failure_threshold: int = 3, reset_after_seconds: int = 60):
        self.failure_counts: dict[str, int] = defaultdict(int)
        self.open_until: dict[str, datetime] = {}
        self.threshold = failure_threshold
        self.reset_after = timedelta(seconds=reset_after_seconds)

    def is_open(self, tool_name: str) -> bool:
        if tool_name not in self.open_until:
            return False
        if datetime.utcnow() >= self.open_until[tool_name]:
            # Reset the breaker
            del self.open_until[tool_name]
            self.failure_counts[tool_name] = 0
            return False
        return True

    def record_failure(self, tool_name: str):
        self.failure_counts[tool_name] += 1
        if self.failure_counts[tool_name] >= self.threshold:
            self.open_until[tool_name] = datetime.utcnow() + self.reset_after

breaker = ToolCircuitBreaker()

Logging tool calls for quality review

The recommendation quality depends entirely on whether the tool calls are correct and the tool results are complete. Without logs, you have no visibility into where the chain breaks down. Log every tool call and result in a structured format:

import uuid
from dataclasses import dataclass, field, asdict
from datetime import datetime

@dataclass
class ToolCallLog:
    session_id: str
    turn_id: str
    round_num: int
    tool_name: str
    tool_input: dict
    result_success: bool
    result_summary: str  # truncated for storage
    latency_ms: int
    timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat())

def logged_execute_tool(
    tool_name: str,
    tool_input: dict,
    session_id: str,
    turn_id: str,
    round_num: int,
) -> dict:
    start = datetime.utcnow()
    result = safe_execute_tool(tool_name, tool_input)
    latency_ms = int((datetime.utcnow() - start).total_seconds() * 1000)

    log = ToolCallLog(
        session_id=session_id,
        turn_id=turn_id,
        round_num=round_num,
        tool_name=tool_name,
        tool_input=tool_input,
        result_success=result.get("success", True),
        result_summary=str(result)[:500],
        latency_ms=latency_ms,
    )

    # Write to your logging backend (Firestore, BigQuery, stdout/JSON)
    print(json.dumps(asdict(log)))
    return result

Review the logs weekly in the first month after launch. The most common issues you will find: the model calling filter_products with an empty product_ids list (because search_products returned nothing), calling check_stock before get_product_details (wasted call), or asking for sizes in a format your catalog does not store. Each of these is fixable by updating the tool description, adding an example to the system prompt, or adjusting the tool schema to prevent the bad input.

The tool use pattern keeps the LLM focused on conversation and reasoning while your code handles data access. This separation makes the system easier to test (unit test the tool handlers independently), easier to monitor (log every tool call), and easier to update (swap the underlying database without touching the prompts).

Writing effective system prompts for recommendation tasks

The system prompt is the primary lever for controlling recommendation behaviour. A weak system prompt produces generic, meandering conversations. A well-structured one guides the model to gather information efficiently and present results in a consistent, useful format.

The most important thing the system prompt must communicate is the expected workflow — what order to call tools in, when to stop searching, and how to format the final recommendation. Without explicit ordering guidance, the model may call check_stock before it has product IDs, or call get_product_details on twenty products when three would suffice.

A strong system prompt for a fashion recommendation chatbot:

You are a product recommendation assistant for an outdoor gear shop.

WORKFLOW:
1. Understand the user's need: ask one clarifying question if the request is ambiguous.
   Do not ask multiple questions at once.
2. Search: call search_products with a focused query. Aim for 10–15 candidates.
3. Filter: call filter_products to narrow by price and size. Target 3–5 products.
4. Detail: call get_product_details on the filtered set.
5. Stock: call check_stock for the top 3 products before recommending.
6. Recommend: present exactly 2–3 products with a brief (2–3 sentence) reason each.

FORMAT FOR RECOMMENDATIONS:
- Lead with the best match for the user's primary constraint (budget, fit, weather).
- Note one trade-off per recommendation (lighter but less durable, etc.).
- If nothing fits well, say so and offer to broaden the search.

NEVER recommend a product that is out of stock in the user's size.
NEVER invent product details not returned by the tools.

Explicit numbered steps prevent the model from skipping steps under the pressure of a complex query. The format instructions ensure that the final response is scannable — users comparing three products do not want three paragraphs of prose, they want a structured comparison they can read at a glance.

Expanding the tool set for richer recommendations

The four tools in the initial design cover the core loop. As the chatbot matures, you will want to add tools that support more sophisticated recommendation scenarios.

Comparison tool. Let the model request a structured side-by-side comparison of two or three products:

{
    "name": "compare_products",
    "description": (
        "Generate a structured comparison table for 2-3 products. "
        "Use when the user is deciding between specific options."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "product_ids": {
                "type": "array",
                "items": {"type": "string"},
                "minItems": 2,
                "maxItems": 3,
                "description": "Exactly 2-3 product IDs to compare",
            },
            "attributes": {
                "type": "array",
                "items": {"type": "string"},
                "description": "Specific attributes to compare (e.g. ['weight', 'waterproof_rating', 'price'])",
            },
        },
        "required": ["product_ids"],
    },
}

Review summary tool. Pull aggregated review insights for a product — common praise, common complaints, what type of buyer tends to be satisfied:

{
    "name": "get_review_summary",
    "description": "Get a summary of customer reviews for a product, including common pros and cons.",
    "input_schema": {
        "type": "object",
        "properties": {
            "product_id": {"type": "string"},
            "aspect": {
                "type": "string",
                "description": "Specific aspect to focus on (e.g. 'durability', 'fit', 'weather_resistance')",
            },
        },
        "required": ["product_id"],
    },
}

Recommendation history tool. For returning users, retrieve what they previously purchased or considered, so the model can avoid recommending the same item twice or can offer an upgrade:

{
    "name": "get_user_history",
    "description": "Retrieve a user's purchase history and previously viewed recommendations.",
    "input_schema": {
        "type": "object",
        "properties": {
            "user_id": {"type": "string"},
            "limit": {"type": "integer", "default": 10},
        },
        "required": ["user_id"],
    },
}

Each new tool added to the set increases the model's capability but also increases the number of tool calls per turn — and therefore the latency and cost. Add tools deliberately: only when you have evidence from logs that users are asking for something the existing tools cannot provide. A chatbot with four well-defined tools used correctly outperforms one with ten loosely defined tools that the model uses inconsistently.

Measuring recommendation quality

Tool use chatbots are harder to evaluate than simple Q&A systems because quality has two independent dimensions: did the model use the tools correctly (process quality), and did the user get a good recommendation (outcome quality)?

Process quality is measurable from logs. Track:

  • Tool call success rate per tool
  • Number of tool rounds per conversation turn (lower is better; more than three rounds suggests the model is exploring inefficiently)
  • Rate of empty results from search_products (may indicate query phrasing problems)
  • Rate of out-of-stock products in the final recommendation (should be near zero)

Outcome quality requires user signals. The simplest signal is add-to-cart or purchase rate from recommended products — if the recommendation chatbot's conversion rate is similar to the site's overall rate, the recommendations are not adding value. A well-tuned chatbot should convert at a meaningfully higher rate for high-intent queries ("I need X that meets these specific requirements") than the site average.

Combine the two: when outcome quality drops, look at whether process quality metrics changed at the same time. A drop in add-to-cart rate that coincides with a jump in empty search results points to a search index problem, not a prompt quality problem. This distinction saves hours of debugging time.

Comments

No comments yet. Be the first!

Sign in to leave a comment.