Tutorials

Build a customer support agent: an open model for routine questions, Claude for hard ones

Build a customer support AI agent in Python. An open model answers order and policy questions and hands refunds and unsure answers to Claude, on one key.

9 min read · Python · Verified on Sep 26, 2026

The project

Picture a small online shop. Most of the support inbox is the same few questions: where is my order, how do returns work, when will it arrive. Each one is quick to answer, and they keep coming all day.

In this tutorial we build a support agent that answers those questions for you. It looks up the customer's own orders and your shop policy, then replies in plain language. A fast, low-cost open model handles the routine questions. When a customer asks for money back, or the open model is unsure of its answer, the agent hands the question to Claude.

At the end you have a Python script that answers two real questions. The first, about a shipped order, stays on the open model. The second, about a mug that arrived broken, goes to Claude. Both run on one SayGM key and one bill.

What you'll learn

  • How to give an agent tools that read your own data, scoped to the signed-in customer.
  • How to ask for a structured reply, with an intent and a confidence flag your code can act on.
  • How to send only the hard questions to a stronger model, with the same agent and the same tools.

How it works

Every question starts on the open model. Your code reads its structured reply and decides whether Claude should answer instead.

Routine questions stay on the open model; refunds and unsure answers go to ClaudeThe agent in your code holds the shop tools lookup_order and search_faq; lookup_order only returns orders of the signed-in customer, whose email your app passes in. The agent first runs the customer's question on an open model, deepseek-v4-flash-0731, through the SayGM gateway, and the structured reply comes back through the gateway to your code. If the intent is a refund or the reply is not confident, your code runs the same agent again on Claude, claude-haiku-4-5, through the gateway, and Claude's answer comes back the same way and becomes the reply. Otherwise the open model's answer is the reply.Agent andtoolslookup_ordersearch_faqRefund orunsure?intent,confidentRerun onClaudesame agentand toolsReplySayGM gatewayOpen modeldeepseek-v4-flash-0731Claudeclaude-haiku-4-5yesno
Your codeSayGM gatewayModel

What you need

  • Python 3.13.
  • A SayGM key in the SAYGM_API_KEY environment variable.
  • An orders.json and a faq.json beside the script. The example has small samples.

Setup

Install Pydantic AI with its OpenAI and Anthropic extras.

commands.sh
pip install "pydantic-ai-slim[anthropic,openai]==2.50.0"

Create both models with the same key. The open model, deepseek-v4-flash-0731, uses Chat Completions at https://api.saygm.com/v1. Claude, claude-haiku-4-5, uses the Messages API at https://api.saygm.com.

main.py
import json
import os
from pathlib import Path
from typing import Literal

from pydantic import BaseModel
from pydantic_ai import Agent, AgentRunResult, RunContext
from pydantic_ai.models.anthropic import AnthropicModel
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.anthropic import AnthropicProvider
from pydantic_ai.providers.openai import OpenAIProvider

api_key = os.environ["SAYGM_API_KEY"]
open_model = OpenAIChatModel(
    "deepseek-v4-flash-0731",
    provider=OpenAIProvider(base_url="https://api.saygm.com/v1", api_key=api_key),
)
claude = AnthropicModel(
    "claude-haiku-4-5",
    provider=AnthropicProvider(base_url="https://api.saygm.com", api_key=api_key),
)

Steps

Load orders and policies

The example reads two JSON files. In your shop these would be calls to your order system and help center. Keeping the data in your code means a model sees only what a tool returns for the question in hand.

main.py
DATA = Path(__file__).parent
ORDERS = {order["order_id"]: order for order in json.loads((DATA / "orders.json").read_text())}
FAQ = json.loads((DATA / "faq.json").read_text())

Give the agent tools and a structured reply

Every reply carries the answer, the customer's intent and whether the agent is confident. The signed-in customer's email reaches the tools as deps, so lookup_order only returns that customer's orders.

The structured reply is what makes the hand-off work: your code reads two fields and acts on them, with no guessing from the wording of an answer.

main.py
class Reply(BaseModel):
    answer: str
    intent: Literal["order_status", "policy", "refund", "other"]
    confident: bool


agent = Agent(
    open_model,
    deps_type=str,
    output_type=Reply,
    instructions=(
        "You are a support agent for an online shop. Use the tools for order and policy facts. "
        "Set intent to refund when the customer asks for money back. "
        "Set confident to false when the tools do not answer the question."
    ),
)


@agent.tool
def lookup_order(ctx: RunContext[str], order_id: str) -> str:
    """Look up one of the signed-in customer's orders by its number."""
    order = ORDERS.get(order_id)
    if order is None or order["email"] != ctx.deps:
        return "The signed-in customer has no order with that number."
    return json.dumps(order)


@agent.tool_plain
def search_faq(topic: str) -> str:
    """Find shop policy by topic: shipping, returns or refunds."""
    matches = [entry for entry in FAQ if topic.lower() in entry["topic"]]
    return json.dumps(matches or FAQ)

Hand hard cases to Claude

Run the question on the open model first, for the signed-in customer. If they want a refund, or the open model is not confident, run the same agent again on Claude.

Refunds are where a wrong answer costs you money, so whenever the open model tags a question as a refund, Claude answers it. Everything else stays on the open model unless it marks its own answer as unsure.

main.py
def answer(question: str, customer_email: str) -> tuple[AgentRunResult[Reply], bool]:
    result = agent.run_sync(question, deps=customer_email)
    escalate = result.output.intent == "refund" or not result.output.confident
    if escalate:
        result = agent.run_sync(question, deps=customer_email, model=claude)
    return result, escalate

Ask it questions

A routine order question stays on the open model. A refund request goes to Claude. In your app, the email comes from the customer's session.

main.py
routine, routine_escalated = answer("Where is order 1001?", customer_email="[email protected]")
print(f"Open model: {routine.output.answer}")

refund, refund_escalated = answer(
    "My mug set from order 1002 arrived broken. I want a refund.",
    customer_email="[email protected]",
)
print(f"Claude: {refund.output.answer}")

Run it

Put the code in main.py in the order above, next to the two JSON files, export your key, and run it. You see one answer from the open model and one from Claude.

commands.sh
python main.py

What you'll see

The open model answers the order question from the order data: carrier, status and delivery date. The refund request goes to Claude, which quotes the refund policy and asks for photos of the damage. That was the routing in our run. The open model makes the call through its intent and confident fields, so a borderline question can go either way on another run.

Terminal
$ python main.py
Open model: Order 1001 (Blue rain jacket, size M) has already been shipped via **DHL** and is expected to be delivered on **October 2, 2026**.
You can track your package using the carrier DHL. Is there anything else I can help with?
Claude: I'm sorry to hear your ceramic coffee mug set from order 1002 arrived broken. I understand you'd like a refund. According to our policy, refunds for damaged items are processed to the original payment method after review. To proceed with your refund request, please contact our customer service team with photos of the damaged items so we can review and process your refund accordingly.
From a run of the example on Sep 27, 2026. Long answers are trimmed, and replies change a little from run to run.

Where this stops

The agent answers questions and decides which model should answer them. Money and account changes stay with people. In a live shop, a refund request should end with a person who approves it, and the agent's job is to collect the order number and the photos first.

A production version adds a conversation history for each customer, a log of every hand-off so you can check the open model's judgement, and a limit on how many questions one session can ask.

Take it further

  • Send each refund case to your help desk as well, with Claude's summary and the order attached.
  • Add a track_parcel tool that calls your carrier's API, so "where is my order" gets a live answer.
  • Pass earlier turns as message history, so follow-up questions like "and the other order?" work.
  • Log every hand-off with the open model's first reply, and read a week of them to tune the instructions.

What it costs

You pay per token, from a prepaid balance. SayGM rates are live and typically below list. USD per 1M tokens, uncached input / output. SayGM’s side is the best rate a provider offered, as of 46 minutes ago.

ModelSayGMList priceYou save
DeepSeek-V4-Flash-0731DeepSeek$0.13 / $0.40$0.44 / $1.3269%
Claude Haiku 4.5Anthropic$0.89 / $4.47$1.00 / $5.0010%

The last verified run of this example used 5,217 tokens, which costs less than one centat today’s SayGM rates.

Swap the model

Change the model id in the code to any model that serves the same API, such as a larger model for harder prompts or a confidential model for private data. This tutorial uses:

Every model, its API and its live rate is on the models page.

Next steps

Questions

Why not send every question to Claude?
Most support questions are routine: where is my order, what is the returns policy. An open model answers those well, at a lower rate per token than Claude. Claude handles the cases where quality matters most.
Is this the same as a fallback model?
No. A fallback model takes over when a request fails. Here the open model answers first, and the agent passes a question to Claude on purpose: refunds, and answers the open model is not sure of.
How does the agent keep one customer from reading another's order?
Your app passes in the signed-in customer's email, never one typed into the chat. The lookup_order tool returns an order only when it belongs to that customer.
Where does customer data go?
Order lookups run in your code, and a model sees only what the agent sends it. Claude requests, including every escalated question, pass through the SayGM gateway to Anthropic. For the questions the open model answers, you can use a confidential model instead: its id ends in -tee and it runs fully sealed.
What does a month of support cost?
Per token, from a prepaid SayGM balance. The table on this page shows live rates and what the last verified run of this example cost, so you can scale it to your volume.

Sources

Run it with your own key.

One SayGM key works for every model in this tutorial. Top up once and pay per token.