Chapter 20 · Capstone 2: Full-Stack AI Application
Chapter goals: Synthesize the entire book's knowledge to build a customer service knowledge-base bot — RAG retrieval + Workflow approval flow + Memory + frontend integration — and complete deployment.
20.1 Requirements and Architecture
Functionality: user asks a question → retrieve from knowledge base to answer; when involving a refund,走 the approval workflow (amount > 500 RMB requires human approval); remember the user's historical requests.
User ─▶ Next.js UI ─▶ API Route ─▶ Agent (RAG tool + Memory)
│ Refund intent
▼
refundFlow (suspend waits for approval)Project structure:
src/
mastra/
index.ts # Mastra instance: storage/trace汇总
tools/kb.ts # RAG retrieval tool
workflows/refund.ts
agents/support.ts
app/
api/chat/route.ts # Streaming接口
page.tsx # Chat UI
scripts/ingest.ts # Knowledge base ingestion script20.2 Knowledge Base Ingestion and Retrieval Tool
// scripts/ingest.ts —— Pour docs/ directory into the vector store
import { MDocument } from '@mastra/rag'
import { embedMany } from 'ai'
import { openai } from '@ai-sdk/openai'
import { LibSQLVector } from '@mastra/libsql'
import { readdirSync, readFileSync } from 'node:fs'
const vector = new LibSQLVector({ url: process.env.LIBSQL_URL! })
await vector.createIndex({ indexName: 'kb', dimension: 1536 })
for (const file of readdirSync('./docs')) {
const doc = MDocument.fromText(readFileSync(`./docs/${file}`, 'utf8'))
const chunks = await doc.chunk({ strategy: 'markdown', size: 512, overlap: 50 })
const { embeddings } = await embedMany({
model: openai.embedding('text-embedding-3-small'),
values: chunks.map((c) => c.text),
})
await vector.upsert({
indexName: 'kb',
vectors: embeddings,
metadata: chunks.map((c) => ({ text: c.text, source: file })),
})
}// src/mastra/tools/kb.ts —— Retrieval tool exposed to the Agent
import { createTool } from '@mastra/core/tools'
import { z } from 'zod'
export const searchKb = createTool({
id: 'search-kb',
description: 'Search policy and process information in the company knowledge base',
inputSchema: z.object({ query: z.string().describe('Search keyword') }),
execute: async ({ context }) => {
const results = await retrieve(context.query) // Retrieval function from Chapter 12
return { passages: results.map((t, i) => ({ id: i, text: t })) }
},
})20.3 Approval Workflow
// src/mastra/workflows/refund.ts
import { createWorkflow, createStep } from '@mastra/core/workflows'
import { z } from 'zod'
const review = createStep({
id: 'review',
inputSchema: z.object({ orderId: z.string(), amount: z.number() }),
outputSchema: z.object({ approved: z.boolean() }),
resumeSchema: z.object({ approved: z.boolean() }), // Approver回传
execute: async ({ inputData, suspend, resumeData }) => {
if (resumeData) return resumeData // Approval result already exists
// Large refund: suspend and wait for human approval; state auto-persisted
await suspend()
return { approved: false }
},
})
export const refundFlow = createWorkflow({
id: 'refund-flow',
inputSchema: z.object({ orderId: z.string(), amount: z.number() }),
outputSchema: z.object({ result: z.string() }),
})
.then(review)
.map(({ inputData }) => ({
result: inputData.approved ? 'Refund approved' : 'Refund rejected',
}))
.commit()20.4 Assembling the Agent and Server
// src/mastra/agents/support.ts
import { Agent } from '@mastra/core/agent'
import { Memory } from '@mastra/memory'
import { LibSQLStore } from '@mastra/libsql'
import { searchKb } from '../tools/kb'
import { mastra } from '../index'
export const support = new Agent({
id: 'support',
name: 'Customer Support',
instructions: `You are a company customer support agent. Call search-kb to query materials before answering;
cite source numbers. For refund requests, confirm the order ID and amount before executing the refund flow.`,
model: 'openai/gpt-5-mini',
tools: { searchKb },
memory: new Memory({
storage: new LibSQLStore({ url: process.env.LIBSQL_URL! }),
options: { observationalMemory: true }, // Long-term memory (Chapter 11)
}),
workflows: { refundFlow }, // Agent can trigger the approval flow
})
export default supportThe frontend复uses the Chapter 17 useChat page; the approval action is completed by the ops后台 calling run.resume().
20.5 Deployment and Acceptance
npm run build && docker build -t support-bot .
docker run -p 4111:4111 \
-e LIBSQL_URL=libsql://prod.turso.io -e LIBSQL_AUTH_TOKEN=$TOKEN \
-e OPENAI_API_KEY=$KEY support-botAcceptance checklist:
- [ ] Pure knowledge Q&A hits the knowledge base and cites source numbers
- [ ] A 501 RMB refund triggers suspend; the conversation receives the result after后台 approval
- [ ] A new session still remembers "bought earbuds last time" (OM生效)
- [ ] Studio shows the完整 trace and eval scores
20.6 Chapter Summary
- Full-stack app = RAG toolized + Workflow approval + Memory long-term + Frontend streaming UI;
- Retrieval capability封装 as a tool so the model自主 decides when to query;
- Large sensitive operations use suspend/resume to引入 a human gate;
- After deployment, check off四条验收项 one by one — none isoptional.
🧪 随堂测验
点击你认为正确的选项。答错时会展示正确答案与原因解析。
1. What triggers the refund workflow to suspend?
2. How does Observational Memory help in this capstone?
3. What is the role of the ingest script?
4. Why is the approval action done via run.resume() rather than in the chat UI?
🛠️ Hands-On Exercises
- Build the full capstone: RAG ingestion script → searchKb tool → support agent with Memory → refundFlow with suspend → Next.js chat UI.
- Test the acceptance checklist: pure Q&A, refund suspension + resume, cross-session memory, Studio traces.
- Deploy to Docker; verify the health endpoint and a real conversation end-to-end.
Capstone 2 complete. Next: Capstone 3: Data Q&A BI Agent.