Agent Playbook, build tutorial
A walkthrough of how to build a support agent that answers customers from your own knowledge base, resolves the routine tickets instantly, reads the room and hands an angry customer straight to a human, takes safe actions like checking an order, and drafts replies to public reviews. Every code step is shown in three frameworks, Google ADK, the Claude SDK, and LangGraph, so you can build it on the stack your team already knows. The code here is the agent's core logic; standing it up in production also means adding the setup, integrations, and hardening around it.
Most support volume is the same questions asked again, and answering them by hand is expensive: Gartner puts a live contact on phone, chat, or email at about $8.01, versus roughly $0.10 for a self-service resolution, and service leaders estimate 20 to 40 percent of live volume could be self-served. Customers want this too: a Harvard Business Review figure has 81 percent of them trying to solve a problem themselves before contacting you. The catch is that self-service rarely works, with Gartner finding only about 14 percent of issues fully resolved that way.
A rule-tree chatbot does not close that gap; it just frustrates people into demanding an agent. A grounded agent is different: it answers from your actual help content, cites where the answer came from, does real tasks like looking up an order, and escalates the hard or emotional cases to a human with full context instead of arguing with them.
You do not build the agent loop from scratch. A framework gives you the moving parts: a step is a model plus an instruction plus a few typed tools. The code tabs on the build steps show the same triage, retrieve, and resolve flow three ways.
Google ADK gives the tools to an agent and runs it with a Runner, the Claude SDK drives a tool-use loop, and LangGraph builds an explicit graph that branches to escalate on anger. The retrieval, the grounding rule, and the tools are the same code in all three; only the orchestration and the model calls differ.
Every ticket flows through one loop: receive and triage, retrieve from the knowledge base, draft an answer, then either resolve when it is confident and safe or escalate to a human otherwise. Resolved and escalated tickets feed a learning loop, and guardrails for grounding, PII, and approvals wrap the whole thing.
Your help docs, policies, product info, and past resolved tickets. The richer and cleaner this is, the more the agent can answer.
A model API key (Gemini, Claude, or both), your channels (WhatsApp, email, web), your helpdesk (Zendesk, Freshdesk), and order or CRM APIs.
Confidence threshold for auto-reply, what may be auto-actioned versus approved, escalation rules, and PII handling.
Install the packages and set your Gemini key. That is enough to run the runnable file below, which answers from a tiny in-memory knowledge base. For production you add a vector store (pgvector, Pinecone) with an embedding model for retrieval, your channels (WhatsApp, email, a web widget), and your helpdesk (Zendesk, Freshdesk); those are provider-specific.
# Python 3.10 or newer
python -m venv venv && source venv/bin/activate
# The runnable path below uses Google ADK + Gemini:
pip install google-adk google-genai
# Your Gemini key from aistudio.google.com/apikey:
export GEMINI_API_KEY="your-key-here" | Layer | Pick | Why |
|---|---|---|
| Channels | WhatsApp Cloud API, email, and a web widget | Meet customers where they already ask. In India that is WhatsApp first, so the agent has to be fluent there. |
| Knowledge base + retrieval | A vector store (pgvector, Pinecone, Weaviate) with RAG | The agent answers from your help docs, not the open internet. Retrieval is what keeps answers grounded and current. |
| Model | Gemini 2.5 Flash, Claude, or any model behind LangChain | Triages intent and sentiment and writes a grounded answer from the retrieved context, in Hindi or English. The code tabs show all three. |
| Agent framework | Google ADK, the Claude SDK, or LangGraph | Drives each ticket through triage, retrieve, resolve or escalate. Pick the one your team runs; the logic is identical. |
| Tools + helpdesk | Your helpdesk API (Zendesk, Freshdesk) and order or CRM APIs | Look up an order, create a ticket, and hand escalations to a human with full context, instead of starting from scratch. |
| Data + monitoring | Postgres plus a dashboard | Store conversations, sources cited, and outcomes, and track deflection, CSAT, and escalation over time. |
The model and orchestration steps show the same logic in Google ADK, the Claude SDK, and LangGraph. Use the tabs to switch. The retrieval, routing, and guardrail steps are plain Python and identical everywhere. These are the building blocks, and they call your own systems (the vector store, the helpdesk, the channels) by name; the complete file you can run today is in "Put it together" below.
The agent's answers are only as good as what it can look up, so the first job is to turn your help docs, policies, and past tickets into searchable memory. You split each document into overlapping chunks, convert them to embeddings, and store them in a vector database. This is retrieval-augmented generation, or RAG. The embedding model is your choice (Gemini, Voyage, or an open model); the indexing itself is the same in every framework.
from langchain_text_splitters import RecursiveCharacterTextSplitter
# embed() wraps your embedding model (Gemini embeddings, Voyage, or open-source).
def index_kb(docs):
splitter = RecursiveCharacterTextSplitter(chunk_size=800, chunk_overlap=120)
rows = []
for d in docs:
for chunk in splitter.split_text(d.text):
rows.append((chunk, embed(chunk), d.source)) # text, vector, source
store.upsert(rows) This single rule is the difference between a useful bot and an embarrassing one. The agent retrieves the top matching chunks, and if nothing is relevant enough it says it does not know and escalates, rather than inventing an answer. When it does answer, it writes strictly from the retrieved context and returns the sources it used. The tabs show the same grounded generation in Gemini, Claude, and LangChain.
from google import genai
client = genai.Client()
def answer(question: str) -> dict:
ctx = store.search(embed(question), k=5)
if not ctx or top_score(ctx) < 0.75:
return {"answer": None, "escalate": True} # we genuinely do not know
resp = client.models.generate_content(
model="gemini-2.5-flash",
contents=[GROUNDED_PROMPT,
"Context:\n" + "\n".join(c.text for c in ctx),
"Question: " + question])
return {"answer": resp.text, "sources": [c.source for c in ctx]} from anthropic import Anthropic
client = Anthropic()
def answer(question: str) -> dict:
ctx = store.search(embed(question), k=5)
if not ctx or top_score(ctx) < 0.75:
return {"answer": None, "escalate": True}
msg = client.messages.create(
model="claude-opus-4-8", max_tokens=512, system=GROUNDED_PROMPT,
messages=[{"role": "user", "content":
"Context:\n" + "\n".join(c.text for c in ctx) + "\n\nQ: " + question}])
return {"answer": msg.content[0].text, "sources": [c.source for c in ctx]} from langchain_google_genai import ChatGoogleGenerativeAI
llm = ChatGoogleGenerativeAI(model="gemini-2.5-flash") # or ChatAnthropic(...)
def answer(question: str) -> dict:
ctx = store.search(embed(question), k=5)
if not ctx or top_score(ctx) < 0.75:
return {"answer": None, "escalate": True}
prompt = (GROUNDED_PROMPT + "\nContext:\n" + "\n".join(c.text for c in ctx)
+ "\n\nQ: " + question)
return {"answer": llm.invoke(prompt).content, "sources": [c.source for c in ctx]} Not every message wants the same handling, so before answering the agent classifies it into a small, typed shape: what the customer wants, how they feel, and how urgent it is. An angry or high-urgency message skips the bot entirely and reaches a human at once. Forcing structured output keeps this routing reliable. The tabs show the same classification three ways.
from google.genai import types
from pydantic import BaseModel
class Triage(BaseModel):
intent: str # how_to | order_status | refund | complaint | other
sentiment: str # happy | neutral | angry
urgency: str # low | normal | high
def triage(text: str) -> Triage:
resp = client.models.generate_content(
model="gemini-2.5-flash",
contents=["Triage this support message.", text],
config=types.GenerateContentConfig(
response_mime_type="application/json", response_schema=Triage))
return resp.parsed # Triage is the same Pydantic schema as the Gemini tab.
def triage(text: str) -> Triage:
msg = client.messages.parse(
model="claude-opus-4-8", max_tokens=64,
messages=[{"role": "user", "content": f"Triage this support message: {text}"}],
output_config={"format": Triage})
return msg.parsed_output # Triage is the same Pydantic schema as the Gemini tab.
triager = ChatGoogleGenerativeAI(
model="gemini-2.5-flash").with_structured_output(Triage)
def triage(text: str) -> Triage:
return triager.invoke(f"Triage this support message: {text}") Now the loop decides. An angry or urgent message escalates straight to a person. Otherwise the agent answers from the KB, and if it is not confident or the answer was not found, it still escalates, but it hands the human a draft reply and the full context. A confident, grounded answer goes back to the customer with its sources. This routing is plain deterministic code.
def handle(text: str):
t = triage(text)
if t.sentiment == "angry" or t.urgency == "high":
return escalate(text, t, reason="needs_a_human") # never bot an angry user
a = answer(text)
if a["escalate"] or a.get("confidence", 1) < policy.min_confidence:
return escalate(text, t, draft=a["answer"]) # hand the human a draft
return reply(text, a["answer"], sources=a["sources"]) Answering questions is half the value; doing things is the other half. Read-only actions such as looking up an order run automatically. Anything that changes state or moves money, such as a refund, is never executed by the agent: it drafts the action and routes it for a human to approve. These are plain typed functions the agent calls as tools.
def get_order_status(order_id: str) -> dict:
"""Look up an order's status by id. Read-only, safe to run automatically."""
o = erp.order(order_id)
return {"status": o.status, "eta": o.eta}
def request_refund(order_id: str, amount: float) -> dict:
"""Refunds move money, so never auto-run: draft for human approval."""
review_queue.add_action("refund", order_id, amount)
return {"queued": True} Reviews are support in public, and tone matters more than speed. The agent reads a Google or app store review, judges the sentiment, and drafts an on-brand reply: apologise and offer to fix for an unhappy one, a warm thank-you for a happy one. Public replies are never auto-posted, because one tone-deaf automated response does real brand damage. A human approves and posts.
def draft_review_reply(review) -> None:
t = triage(review.text)
tone = "apologise_and_fix" if t.sentiment == "angry" else "thank"
# draft() wraps the same model call as answer(), in your chosen framework.
draft = draft(review=review.text, tone=tone, brand=BRAND_VOICE)
approvals.add(draft, channel=review.platform) # human posts it Every escalation is a gap in the knowledge base, and the agent uses them to improve itself. On a schedule it gathers the questions it had to escalate, clusters the similar ones, and drafts a new help article for each common cluster. A human reviews each draft before it is indexed, so the agent answers next month what it could not answer this month, and deflection rises over time.
def learn_from_resolved(window="7d") -> None:
misses = tickets.where(escalated=True, period=window)
clusters = cluster(misses, by="question_embedding")
for c in top(clusters, n=10):
article = draft_faq(examples=c.tickets) # a new help article
kb.propose(article) # human reviews, then index In support, the safety rails are not optional. The agent redacts personal data such as card or Aadhaar numbers so it never echoes them back, and any write action, refund, or public post waits for human approval. Every interaction is written to an append-only audit log, and a dashboard tracks deflection rate, CSAT, escalation rate, and first response time, with an alert when deflection drops.
def safe_reply(text: str, action=None) -> str:
if contains_pii(text):
text = redact(text) # never echo card / Aadhaar numbers
if action and action.is_write and not action.approved:
hold(action) # refunds and public posts need a human
return text
# audit.append(ticket, action); metrics.track(deflection_rate, csat,
# escalation_rate, first_response_time); alert_if(deflection_rate < target) Now wire triage, retrieval, and the tools into one runnable agent. ADK gives the tools to an LlmAgent and runs it with a Runner, the Claude SDK drives a tool-use loop, and LangGraph builds a graph that branches to escalate on anger and otherwise answers from the KB. The grounding, the schema, and the tools are the same in all three.
from google.adk.agents import LlmAgent
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
support_agent = LlmAgent(
name="support_agent", model="gemini-2.5-flash",
instruction="Triage the message. If angry or urgent, call escalate. Otherwise "
"answer from the KB with answer(); if it returns escalate, call "
"escalate. Use get_order_status for order questions.",
tools=[triage, answer, get_order_status, request_refund, escalate])
runner = Runner(agent=support_agent, app_name="support",
session_service=InMemorySessionService()) from anthropic import Anthropic
client = Anthropic()
TOOLS = [triage_tool, answer_tool, order_tool, refund_tool, escalate_tool]
def run_support(messages: list):
while True:
resp = client.messages.create(model="claude-opus-4-8", max_tokens=1024,
tools=TOOLS, messages=messages)
if resp.stop_reason != "tool_use":
return resp
messages.append({"role": "assistant", "content": resp.content})
for b in resp.content:
if b.type == "tool_use":
out = dispatch(b.name, b.input)
messages.append({"role": "user", "content": [
{"type": "tool_result", "tool_use_id": b.id, "content": str(out)}]}) from langgraph.graph import StateGraph, START, END
def do_triage(s): return {"t": triage(s["text"])}
def do_answer(s): return {"a": answer(s["text"])}
def route(s):
t = s["t"]
return "escalate" if t.sentiment == "angry" or t.urgency == "high" else "answer"
g = StateGraph(dict)
g.add_node("triage", do_triage); g.add_node("answer", do_answer)
g.add_node("escalate", lambda s: {"escalated": escalate(s["text"], s["t"])})
g.add_edge(START, "triage")
g.add_conditional_edges("triage", route)
g.add_conditional_edges("answer",
lambda s: "escalate" if s["a"]["escalate"] else END)
g.add_edge("escalate", END)
support_agent = g.compile()
Here is a runnable file. It answers one question strictly from a tiny
in-memory knowledge base and prints the grounded reply with its source.
Replace KB and search with your vector store and embeddings. Save it as
main.py and run
python main.py "how do refunds work?".
# main.py -- run: python main.py "how do refunds work?"
# A runnable reference: answers a question strictly from a tiny in-memory
# knowledge base. Replace KB and search() with your vector store + embeddings.
import sys, asyncio
from google import genai
from google.genai import types
from google.adk.agents import LlmAgent
from google.adk.runners import InMemoryRunner
client = genai.Client() # reads GEMINI_API_KEY
# ---- a tiny knowledge base (replace with your vector store) ----
KB = [
"Orders ship within 2 business days. Track them from the link in your confirmation email.",
"Returns are accepted within 7 days of delivery for unused items in original packaging.",
"Refunds go back to the original payment method within 5 to 7 business days.",
]
def search(query: str, k: int = 2) -> list[str]:
# Demo retrieval ranks by shared words. Swap for embeddings + a vector store.
q = set(query.lower().split())
return sorted(KB, key=lambda d: len(q & set(d.lower().split())), reverse=True)[:k]
# ---------------------------------------------------------------
def answer(question: str) -> dict:
"""Answer strictly from the retrieved passages, or escalate if not covered."""
ctx = search(question)
if not ctx:
return {"answer": None, "escalate": True}
resp = client.models.generate_content(
model="gemini-2.5-flash",
contents=["Answer ONLY from the context. If it is not covered, say you do "
"not know.\nContext:\n" + "\n".join(ctx) + "\n\nQ: " + question])
return {"answer": resp.text, "sources": ctx}
agent = LlmAgent(
name="support_agent", model="gemini-2.5-flash",
instruction="Call answer on the user's question and return the grounded reply.",
tools=[answer])
async def main(question: str):
runner = InMemoryRunner(agent=agent, app_name="support")
session = await runner.session_service.create_session(app_name="support", user_id="demo")
msg = types.Content(role="user", parts=[types.Part(text=question)])
async for event in runner.run_async(user_id="demo", session_id=session.id, new_message=msg):
if event.is_final_response():
print(event.content.parts[0].text)
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit('usage: python main.py "<a customer question>"')
asyncio.run(main(sys.argv[1])) You should see a grounded answer drawn only from the matching passage, with its source. Ask something the knowledge base does not cover and it declines rather than inventing an answer. Triage, safe actions, and review replies stay in the build steps above, which add your channels and helpdesk.
Your team sees one screen: the queue, the conversation, and the agent's suggested answer with its sources, ready to send or escalate.
| Approach | Best for | Effort | Cost | Control |
|---|---|---|---|---|
| Custom agent (build in-house) | You want answers grounded on your own KB, your tools, and full control | High | Build cost + model usage | Full |
| Helpdesk AI add-on (Zendesk AI, Freshdesk Freddy, Intercom Fin) | Standard deflection on an existing helpdesk, fast start | Low | Per-resolution or seat subscription | Medium |
| No-code chatbot (rule trees) | A handful of fixed FAQs, a quick pilot | Low | Cheap, but brittle and cannot really understand | Low |
The whole reason a support agent can be trusted is that it answers from your content and admits when it cannot. Retrieval gives it the relevant passages, a strict prompt keeps it inside them, every answer carries its sources, and a confidence floor sends anything weak to a human. That is what prevents the confident, wrong answer that destroys trust faster than any slow reply ever could.
The escalation boundary is just as important. Angry or urgent messages, low-confidence answers, anything that moves money, and any public reply all cross to a human, with the agent handing over a draft and full context. Personal data is redacted so it is never echoed back, every action is logged, and the DPDP Act 2023 obligations on the customer data in tickets are handled in the build, not afterwards.
Benchmarks vary by source and should be treated as ranges, not promises. These are from neutral research, not vendor marketing.
| Metric | Manual | Agent |
|---|---|---|
| Cost per contact | ~$8.01 per live contact on phone, chat, or email (Gartner) | ~$0.10 per self-service contact the agent resolves (Gartner) |
| Live volume that is deflectable | Service leaders estimate 20 to 40 percent could be self-served (Gartner) | Captured, with routine tickets answered instantly |
| Issues fully resolved in self-service today | Only about 14 percent (Gartner, 2024) | Higher, because answers are grounded in your KB, not a generic page |
| Customers who try self-help first | 81 percent attempt it before contacting you (Harvard Business Review) | Met with a real answer instead of a dead FAQ page |
Sources: Gartner Customer Service and Support research; Harvard Business Review. Figures are industry ranges.
Everything you need to know about the service and how it works. Can’t find an answer? Mail us at info@galific.com
Galific designs, builds, and runs support agents like this on ADK, the Claude SDK, or LangGraph, grounded on your knowledge base and wired into your helpdesk. Or explore the ready-made versions, from the L1 Support Bot to the FAQ Generator and Review Reply Assistant, in our agent suite.