Customer Support Agent
The nightmare version of a support agent is the one that improvises policy. A customer asks about refunds, your docs say nothing, and the model helpfully invents a 90-day money-back guarantee in your brand voice. Now you're either honoring a policy written by a language model or telling a customer the robot lied to them. Both options are bad, and both were avoidable.
The rule that prevents it is grounding: the agent may only state what it can quote from your docs folder. No doc, no answer. Anything it can't ground gets escalated to you with a note explaining what the customer needs and what's missing from the docs. That second part is quietly the best feature here: your escalation queue becomes a list of documentation you should have written.
Every reply is a draft printed for your approval. The stub tools are stubs on purpose, same as the other agent templates on this site: wire them to your real inbox one at a time, verify each, and let the agent earn autopilot on the easy tickets before you even think about the hard ones.
Prerequisites
- Python 3.10+ with CrewAI installed: `pip install crewai crewai-tools`.
- An LLM API key exported as an environment variable, never pasted into the file.
- A `docs/` folder of markdown files: your FAQ, policies, and how-tos. This is the only thing the agent is allowed to know.
"""
Customer Support Agent
- Reads new tickets from a JSONL inbox (webhook- or export-fed).
- Answers ONLY from the docs/ folder, quoting its source.
- Escalates anything it cannot ground, with a note on what's missing.
- Every reply is a DRAFT printed for approval. Nothing sends itself.
"""
import json
from pathlib import Path
from crewai import Agent, Task, Crew, Process
from crewai_tools import tool
DOCS_DIR = Path("docs")
INBOX = Path("inbox/tickets.jsonl")
ESCALATIONS = Path("outbox/escalations.jsonl")
# ---------- Stub tools (replace with real implementations) ----------
@tool("read_new_tickets")
def read_new_tickets() -> str:
"""Read pending support tickets. Each line: {id, from, subject, body}."""
if not INBOX.exists():
return "[]"
tickets = [json.loads(l) for l in INBOX.read_text().splitlines() if l.strip()]
return json.dumps(tickets)
@tool("search_docs")
def search_docs(query: str) -> str:
"""Search the docs folder. Returns matching passages with file names.
Naive keyword search on purpose: swap in embeddings later if your
docs outgrow it. Start simple; verify it finds what you'd find."""
words = [w.lower() for w in query.split() if len(w) > 3]
hits = []
for doc in sorted(DOCS_DIR.glob("**/*.md")):
for para in doc.read_text().split("\n\n"):
score = sum(1 for w in words if w in para.lower())
if score >= max(1, len(words) // 2):
hits.append({"file": str(doc), "passage": para.strip()})
return json.dumps(hits[:8])
@tool("draft_reply")
def draft_reply(ticket_id: str, reply: str, sources: str) -> str:
"""Queue a reply draft for human approval. sources = the doc files
the reply is grounded in, comma-separated."""
print(f"\n[DRAFT for ticket {ticket_id}] (sources: {sources})")
print(reply)
print("[END DRAFT - awaiting your approval]\n")
return "drafted"
@tool("escalate_to_human")
def escalate_to_human(ticket_id: str, reason: str, missing_doc: str) -> str:
"""Escalate a ticket the docs can't answer. missing_doc = what page
or policy would have answered it, so the docs improve over time."""
ESCALATIONS.parent.mkdir(parents=True, exist_ok=True)
with open(ESCALATIONS, "a") as f:
f.write(json.dumps({"ticket_id": ticket_id, "reason": reason,
"missing_doc": missing_doc}) + "\n")
print(f"[ESCALATED] ticket {ticket_id}: {reason}")
return "escalated"
# ---------- Agent ----------
support_rep = Agent(
role="Support Representative",
goal=(
"Answer customer tickets using ONLY passages returned by "
"search_docs. Escalate anything the docs do not cover."
),
backstory=(
"You are careful and honest. You never guess at policy, "
"pricing, refunds, or timelines. If the docs don't say it, "
"you don't say it. A fast wrong answer is worse than an "
"escalation."
),
tools=[read_new_tickets, search_docs, draft_reply, escalate_to_human],
verbose=True,
)
triage_task = Task(
description=(
"Read all new tickets. For each ticket:\n"
"1. Search the docs for an answer.\n"
"2. If the docs clearly answer it: draft a friendly reply that "
"paraphrases the docs, and list the source files. Do not add "
"commitments, discounts, or exceptions the docs don't state.\n"
"3. If the docs are silent, partial, or contradictory: escalate "
"with the reason and what missing doc would have answered it.\n"
"4. If the customer is angry or mentions refunds over $[100], "
"legal action, or data deletion: escalate regardless of docs.\n\n"
"Output: JSON list of {ticket_id, action, sources_or_reason}."
),
agent=support_rep,
expected_output="JSON list of triage actions.",
)
crew = Crew(agents=[support_rep], tasks=[triage_task],
process=Process.sequential, verbose=True)
if __name__ == "__main__":
print(crew.kickoff())Adaptation notes:
- Wire
read_new_ticketsanddraft_replyto your real inbox (Gmail API, help desk webhook) one at a time, and keep the print-for-approval step until you have read a few weeks of drafts without wincing. - The escalation triggers in step 4 are yours to set. Money thresholds, legal keywords, named angry customers: anything where a wrong answer costs more than a delay belongs on that list.
- Review
outbox/escalations.jsonlweekly. Each entry is a doc you should write; every doc you write shrinks next week's escalation queue. - Tickets contain customer data. Keep the inbox and outbox out of the repo, and check what your LLM provider does with API traffic before you send real names through it (§6.5 covers this).
- The mistake people make: letting the agent answer from its general knowledge when the doc search comes up empty, because the answer "sounded right." That is exactly the improvised-policy failure this whole template exists to prevent. Empty search means escalate, every time.