The Problem
Your docs answer 80% of support questions. The other 20% need a human. The problem is routing - someone asks a question, gets silence or a generic "check the docs" response, and churns before your team even knows they needed help.
What if your support agent could answer from the docs and automatically escalate anything it can't handle - right to your team's inbox?
How It Works
The Support Agent is built on top of the Docs Assistant template. It keeps the full docs search flow and adds one critical capability: email escalation via Resend.
Here's the flow:
- On startup - the agent downloads
llms.txtandllms-full.txtfrom your docs site - User asks a question - the agent searches docs locally, fetching specific pages if needed
- If the docs have the answer - Claude responds with a cited, accurate answer
- If the docs don't have the answer - the agent offers to escalate, then sends an email to your team with the full question via Resend
- Your team gets a clean email - with the user's question, context, and a note that the docs didn't cover it
The escalation is opt-in - the agent asks the user before sending, so you only get the questions that genuinely need human attention.
Agent Tools
| Tool | What it does |
|---|---|
search_docs | Grep through downloaded docs with keyword + context |
list_doc_pages | Show full docs index from llms.txt |
fetch_doc_page | Fetch a specific doc page for full content |
send_email | Forward unanswered questions to your team via Resend |
Environment Variables
| Variable | Where | Description |
|---|---|---|
API_KEY_21ST | .env.local | Server-side API key for token exchange |
DOCS_URL | Agent env | Base URL of your docs site |
RESEND_API_KEY | Agent env | Resend API key (re_) |
SUPPORT_EMAIL | Agent env | Team email where questions are forwarded |
RESEND_FROM_EMAIL | Agent env (optional) | Sender address, defaults to onboarding@resend.dev |
Getting Started
Clone and deploy:
git clone https://github.com/21st-dev/21st-sdk-examples.git
cd 21st-sdk-examples/support-agent
npm install
npx @21st-sdk/cli login
npx @21st-sdk/cli deploy
Set Environment Variables
npx @21st-sdk/cli env set support-agent DOCS_URL https://docs.example.com
npx @21st-sdk/cli env set support-agent RESEND_API_KEY re_your_key_here
npx @21st-sdk/cli env set support-agent SUPPORT_EMAIL team@yourcompany.com
npx @21st-sdk/cli env set support-agent RESEND_FROM_EMAIL "Support <support@yourcompany.com>"
Redeploy after adding the variables:
npx @21st-sdk/cli deploy
Run the Frontend
cp .env.example .env.local
# Add your API_KEY_21ST to .env.local
npm run dev
Set Up Resend
- Sign up at resend.com
- Create an API key in the Resend dashboard
- Add
RESEND_API_KEYandSUPPORT_EMAILas environment variables - Redeploy
If you want a custom sender domain, add and verify it in Resend's dashboard, then set RESEND_FROM_EMAIL.
What the Escalation Email Looks Like
When the agent escalates, your team receives:
- The user's original question
- A note that the docs didn't contain an answer
- A request for follow-up
It's a lightweight signal that helps you identify gaps in your documentation and reach out to users who need help - before they give up.
Build Your Own
Fork this template, change the email format, add a Slack notification on escalation, or wire it into your support ticket system. The 21st SDK makes it straightforward to extend the agent with new tools.
Check the full source on GitHub and deploy your own with 21st SDK.
Agent Template
The full agent source - set DOCS_URL, RESEND_API_KEY, and SUPPORT_EMAIL, then deploy.
import { agent, tool, Sandbox } from "@21st-sdk/agent"
import { z } from "zod"
export default agent({
runtime: "claude-code",
model: "claude-sonnet-4-6",
permissionMode: "bypassPermissions",
maxTurns: 25,
systemPrompt: `You are a support agent. Answer questions using the product
documentation. When you cannot find an answer, offer to forward via email.
ESCALATION - when to use send_email:
- You searched thoroughly and could not find a relevant answer.
- The user explicitly asks to contact the team.
- The question involves account-specific issues or billing.
WORKFLOW:
1. Grep /workspace/llms.txt to find relevant pages.
2. Grep /workspace/llms-full.txt for detailed content.
3. Use fetch_doc_page if you need full page content.
4. If no answer found, ask to forward the question - then use send_email.`,
sandbox: Sandbox({
setup: [
`mkdir -p /home/user/workspace && cd /home/user/workspace && \
if [ -n "$DOCS_URL" ]; then \
BASE=$(echo "$DOCS_URL" | sed 's|/$||'); \
curl -fsSL "$BASE/llms.txt" -o llms.txt 2>/dev/null || echo "# Could not fetch" > llms.txt; \
curl -fsSL "$BASE/llms-full.txt" -o llms-full.txt 2>/dev/null || true; \
else \
echo "# No DOCS_URL configured." > llms.txt; \
fi`,
],
}),
tools: {
fetch_doc_page: tool({
description: "Fetch a specific documentation page by URL.",
inputSchema: z.object({ url: z.string().url() }),
execute: async ({ url }) => {
const res = await fetch(url, { signal: AbortSignal.timeout(15_000) })
const text = await res.text()
return { content: [{ type: "text", text: text.slice(0, 30_000) }] }
},
}),
search_docs: tool({
description: "Search local documentation files for a keyword.",
inputSchema: z.object({
query: z.string().min(1),
file: z.enum(["llms.txt", "llms-full.txt"]).optional().default("llms-full.txt"),
}),
execute: async ({ query, file }) => {
const { execSync } = await import("child_process")
const result = execSync(
`grep -i -n -C 5 ${JSON.stringify(query)} /home/user/workspace/${file} | head -200`,
{ encoding: "utf-8", timeout: 10_000 },
)
return { content: [{ type: "text", text: result || "No matches found." }] }
},
}),
send_email: tool({
description: "Escalate to support team via Resend. Use after confirming with the user.",
inputSchema: z.object({
subject: z.string().min(1),
message: z.string().min(1),
user_email: z.string().email().optional(),
}),
execute: async ({ subject, message, user_email }) => {
const apiKey = process.env.RESEND_API_KEY
const to = process.env.SUPPORT_EMAIL
if (!apiKey || !to) {
return { content: [{ type: "text", text: "RESEND_API_KEY or SUPPORT_EMAIL not configured." }], isError: true }
}
const res = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({
from: process.env.RESEND_FROM_EMAIL || "onboarding@resend.dev",
to: [to],
subject: `[Support Agent] ${subject}`,
text: user_email ? `${message}\n\n---\nUser: ${user_email}` : message,
...(user_email && { reply_to: user_email }),
}),
signal: AbortSignal.timeout(15_000),
})
const data = await res.json()
return { content: [{ type: "text", text: res.ok ? `Email sent (id: ${data.id})` : "Failed to send." }] }
},
}),
},
})



