Chuyển tới nội dung chính

Todo App

Todo App là ví dụ minh hoạ một AI App hoàn chỉnh: AI agent quản lý danh sách công việc qua ngôn ngữ tự nhiên, widget React hiển thị giao diện tương tác trực tiếp trong chat.

Source code: template ai-app-todo — tạo project mới bằng lệnh v-miniapp-cli create

Cần đọc trước?
  • Kiến trúc AI App — sơ đồ tổng thể các thành phần: Tools, Skills, Widget hooks
  • Quick Start — cài đặt CLI, scaffold và chạy project lần đầu

Tính năng

Tính năngCách dùngTool
Xem danh sách"Cho tôi xem danh sách việc cần làm"list-todos
Thêm công việc"Thêm mua sữa vào danh sách" hoặc nhấn Thêm trên widgetadd-todo
Đánh dấu hoàn thànhNhấn checkbox trên widgettoggle-todo
Xoá công việcNhấn nút xoá trên widgetdelete-todo
Đa ngôn ngữTự động theo ngôn ngữ thiết bị (VI / EN)

Điểm nổi bật của luồng:

  • AI gọi tool → server trả về structuredContent.todos → host mở widget
  • Widget nhận data ban đầu qua useToolInfo, sau đó tự gọi tool qua useCallTool khi người dùng tương tác — không cần nhắn thêm tin nhắn

Cấu trúc project

server/src/
index.ts ← khởi tạo McpServer, đăng ký tools/resource/skill
tools/
list-tool.ts ← list-todos: lấy danh sách
add-tool.ts ← add-todo: thêm mới
toggle-tool.ts ← toggle-todo: đổi trạng thái
delete-tool.ts ← delete-todo: xoá
db/
todo-store.ts ← in-memory store (mô phỏng database)

web/src/widgets/manage-todos/
index.tsx ← widget UI + entry point

skills/
list-todos/SKILL.md ← hướng dẫn AI khi nào gọi list-todos

Server

Tham khảo đầy đủ: MCP ServerMcpServer, McpTool, registerResources, registerSkills.

Khởi tạo

server/src/index.ts
import { McpServer } from '@v-miniapp/ai/server'
import { addTodoTool } from './tools/add-tool'
import { deleteTodoTool } from './tools/delete-tool'
import { listTodoTool } from './tools/list-tool'
import { toggleTodoTool } from './tools/toggle-tool'

const server = new McpServer()
// Khai báo widget "manage-todos" — host mở iframe này khi AI gọi tool có resourceName khớp.
// _meta.ui.csp.resourceDomains: cho phép widget load font từ Google Fonts.
.registerResources([
{
name: 'manage-todos',
_meta: {
ui: {
csp: { resourceDomains: ['https://fonts.gstatic.com'] },
},
},
},
])
// Đăng ký skill "list-todos" — AI đọc skills/list-todos/SKILL.md để biết khi nào gọi tool.
.registerSkills(['list-todos'])
// Đăng ký 4 tools — AI có thể gọi bất kỳ tool nào trong danh sách này.
.registerTool(addTodoTool)
.registerTool(listTodoTool)
.registerTool(toggleTodoTool)
.registerTool(deleteTodoTool)

server.run()

export type IServer = typeof server

Data store

Todo được lưu in-memory (mô phỏng database). Tất cả tools đều gọi todosResult() — hàm helper trả về cả content (văn bản cho AI đọc) và structuredContent.todos (data cho widget):

server/src/db/todo-store.ts
export interface Todo {
id: string
text: string
completed: boolean
}

const store = { todos: [] as Todo[], nextId: 1 }

export function getTodos(): Todo[] {
return store.todos
}
export function addTodo(text: string): Todo {
const todo = { id: String(store.nextId++), text, completed: false }
store.todos.push(todo)
return todo
}
export function findTodo(id: string) {
return store.todos.find(t => t.id === id)
}
export function removeTodo(id: string) {
store.todos = store.todos.filter(t => t.id !== id)
}

// Helper dùng chung cho tất cả tools
export function todosResult(message: string) {
return {
content: [{ type: 'text' as const, text: message }],
structuredContent: { todos: store.todos }, // ← widget đọc từ đây
}
}

Các Tools

Mỗi tool đều gắn resourceName: 'manage-todos' — khi AI gọi tool, host tự động mở widget manage-todos.

list-todos — lấy toàn bộ danh sách (không có input):

server/src/tools/list-tool.ts
export const listTodoTool = new McpTool({
name: 'list-todos',
resourceName: 'manage-todos',
config: {
title: 'Danh sách việc cần làm',
description:
'Lấy toàn bộ danh sách các mục việc cần làm, bao gồm ID, nội dung và trạng thái hoàn thành',
},
handler: async () => {
const todos = getTodos()
return todosResult(
todos.length === 0
? 'Chưa có việc cần làm nào.'
: todos
.map(t => `- [${t.completed ? 'x' : ' '}] ${t.text} (id: ${t.id})`)
.join('\n'),
)
},
})

add-todo — thêm công việc mới:

server/src/tools/add-tool.ts
export const addTodoTool = new McpTool({
name: 'add-todo',
resourceName: 'manage-todos',
config: {
title: 'Thêm việc cần làm',
description: 'Tạo một mục việc cần làm mới',
inputSchema: { text: z.string().describe('Tiêu đề của mục việc cần làm') },
},
handler: async ({ text }) => {
const todo = addTodo(text)
return todosResult(`Đã thêm "${text}" (id: ${todo.id}).`)
},
})

toggle-todo — đánh dấu hoàn thành / chưa hoàn thành:

server/src/tools/toggle-tool.ts
export const toggleTodoTool = new McpTool({
name: 'toggle-todo',
resourceName: 'manage-todos',
config: {
title: 'Chuyển trạng thái việc cần làm',
description: 'Chuyển đổi trạng thái hoàn thành của một mục',
inputSchema: {
id: z.string().describe('ID của mục việc cần chuyển trạng thái'),
},
},
handler: async ({ id }) => {
const todo = findTodo(id)
if (!todo) return todosResult(`Không tìm thấy mục việc (id: ${id}).`)
todo.completed = !todo.completed
return todosResult(`Đã chuyển trạng thái "${todo.text}".`)
},
})

delete-todo — xoá công việc:

server/src/tools/delete-tool.ts
export const deleteTodoTool = new McpTool({
name: 'delete-todo',
resourceName: 'manage-todos',
config: {
title: 'Xoá việc cần làm',
description: 'Xoá vĩnh viễn một mục khỏi danh sách bằng ID',
inputSchema: { id: z.string().describe('ID của mục việc cần xoá') },
},
handler: async ({ id }) => {
const todo = findTodo(id)
if (!todo) return todosResult(`Không tìm thấy mục việc (id: ${id}).`)
removeTodo(id)
return todosResult(`Đã xoá "${todo.text}".`)
},
})

Widget

Tham khảo đầy đủ: MCP AppsAppProvider, renderWidget, useToolInfo, useCallTool, useHostContext.

Luồng hoạt động

Người dùng nhắn: "Cho tôi xem danh sách việc cần làm"


AI Agent gọi tool list-todos


Server handler → trả về:
content: "- [ ] Mua sữa (id: 1)\n- [x] Học React (id: 2)"
structuredContent: {
todos: [
{ id: "1", text: "Mua sữa", completed: false },
{ id: "2", text: "Học React", completed: true }
]
}


Host mở widget manage-todos


useToolInfo<'list-todos'>() ← nhận kết quả tool từ AI
getStructuredResult(toolInfo)?.todos

▼ khởi tạo useState(todos) → render list
┌─────────────────────────────┐
│ TODO │
│ [________________] [Thêm] │
│ ☐ Mua sữa [🗑] │
│ ✓ Học React [🗑] │
└─────────────────────────────┘

Người dùng nhấn "Thêm" với text "Đi chợ"


useCallTool('add-todo').callTool({ text: "Đi chợ" }) ← widget tự gọi tool


Server add-tool → trả về todos mới (3 items)

setTodos(getStructuredResult(result)?.todos) → re-render


┌─────────────────────────────┐
│ TODO │
│ [________________] [Thêm] │
│ ☐ Mua sữa [🗑] │
│ ✓ Học React [🗑] │
│ ☐ Đi chợ [🗑] │
└─────────────────────────────┘

useToolInfo + getStructuredResult — nhận data từ AI

useToolInfo<ToolName>() lắng nghe kết quả khi AI Agent gọi tool và mở widget. Widget không chủ động gọi — nó chỉ nhận data do AI kích hoạt.

Dùng getStructuredResult để trích xuất structuredContent một cách an toàn:

const toolInfo = useToolInfo<'list-todos'>()
const data = getStructuredResult(toolInfo)
// data?.todos — type-safe, undefined nếu tool chưa success

Trong Todo App, dùng để lấy danh sách ban đầu khi host mở widget:

const toolInfo = useToolInfo<'list-todos'>()
const data = getStructuredResult(toolInfo)

// Khởi tạo state từ kết quả AI trả về
const [todos, setTodos] = useState(data?.todos || [])
getStructuredResult

getStructuredResult kiểm tra isSuccess trước khi truy cập structuredContent. Nếu tool chưa hoàn thành hoặc thất bại, trả về undefined — không cần kiểm tra thủ công.

→ Xem thêm: MCP Apps — useToolInfo


useCallTool — widget tự gọi tool

useCallTool(toolName) cho phép widget chủ động gọi tool khi người dùng tương tác — không cần AI can thiệp. Hàm callTool(args) trả về Promise với kết quả từ server.

const addTool = useCallTool('add-todo')

Hook trả về object có shape:

addTool.status       → 'idle' | 'pending' | 'success' | 'error'
addTool.data → kết quả lần gọi gần nhất
addTool.error → thông tin lỗi
addTool.callTool(args) → gọi tool, trả về Promise<result>

Trong Todo App, mỗi hành động người dùng tương ứng một useCallTool:

const addTodoTool = useCallTool('add-todo')
const toggleTodoTool = useCallTool('toggle-todo')
const deleteTodoTool = useCallTool('delete-todo')

// Thêm todo khi nhấn nút "Thêm"
const handleAdd = async () => {
const text = newText.trim()
if (!text) return
const result = await addTodoTool.callTool({ text })
setTodos(getStructuredResult(result)?.todos ?? [])
setNewText('')
}

// Đổi trạng thái khi nhấn checkbox
const handleToggle = async (id: string) => {
const result = await toggleTodoTool.callTool({ id })
setTodos(getStructuredResult(result)?.todos ?? [])
}

// Xoá khi nhấn nút xoá
const handleDelete = async (id: string) => {
const result = await deleteTodoTool.callTool({ id })
setTodos(getStructuredResult(result)?.todos ?? [])
}
Hiển thị trạng thái loading

Dùng isPending để disable nút hoặc hiển thị spinner. Có thể gộp nhiều tool lại:

const loading = addTodoTool.isPending
|| listTodosTool.isPending
|| toggleTodoTool.isPending
|| deleteTodoTool.isPending

<Button disabled={loading || !newText.trim()}>
{t('add')}
</Button>
{loading && <Icon name="loader" animation="spin" size={20} />}

→ Xem thêm: MCP Apps — useCallTool


Code widget đầy đủ

web/src/widgets/manage-todos/index.tsx
import {
getStructuredResult,
renderWidget,
useCallTool,
useToolInfo,
} from '@v-miniapp/ai/web'
import {
Button,
Checkbox,
Icon,
TextField,
Typography,
useTranslate,
} from '@v-miniapp/ui-react'
import { useState } from 'react'
import '../../locales'
import './index.css'

export function TodoApp() {
const t = useTranslate()
const toolInfo = useToolInfo<'list-todos'>()
const data = getStructuredResult(toolInfo)

const [newText, setNewText] = useState('')
const [todos, setTodos] = useState(data?.todos || [])
const addTodoTool = useCallTool('add-todo')
const listTodosTool = useCallTool('list-todos')
const toggleTodoTool = useCallTool('toggle-todo')
const deleteTodoTool = useCallTool('delete-todo')

const loading =
addTodoTool.isPending ||
listTodosTool.isPending ||
toggleTodoTool.isPending ||
deleteTodoTool.isPending

const handleAdd = async () => {
const text = newText.trim()
if (!text) return
const result = await addTodoTool.callTool({ text })
setTodos(getStructuredResult(result)?.todos ?? [])
setNewText('')
}

const handleToggle = async (id: string) => {
const result = await toggleTodoTool.callTool({ id })
setTodos(getStructuredResult(result)?.todos ?? [])
}

const handleDelete = async (id: string) => {
const result = await deleteTodoTool.callTool({ id })
setTodos(getStructuredResult(result)?.todos ?? [])
}

return (
<main className="mx-auto p-4 flex flex-col gap-2 bg-alias-background">
<div className="flex items-center gap-1">
<Typography component="h1" size="2x-large" weight="bold">
TODO
</Typography>
{loading && <Icon name="loader" animation="spin" size={20} />}
</div>

<form
onSubmit={e => {
e.preventDefault()
handleAdd()
}}
className="flex gap-2 items-end">
<TextField
className="flex-1"
value={newText}
onChange={setNewText}
placeholder={t('add.placeholder')}
/>
<Button
htmlType="submit"
className="min-h-11! h-11!"
size="large"
disabled={loading || !newText.trim()}
leadingIcon={<Icon name="plus" />}>
{t('add')}
</Button>
</form>

<div className="flex flex-col gap-2">
{todos.map(todo => (
<div
key={todo.id}
className="flex items-center gap-2 px-3 py-2 rounded-[8px] bg-card border border-alias-border-subtle-01"
onClick={() => {
if (!loading) handleToggle(todo.id)
}}>
<Checkbox checked={todo.completed} />
<Typography
component="span"
className={`flex-1 ${todo.completed ? 'line-through text-muted-foreground' : ''}`}>
{todo.text}
</Typography>
<Button
type="ghost"
disabled={loading}
leadingIcon={<Icon name="trash" size={18} />}
onClick={() => handleDelete(todo.id)}
/>
</div>
))}
</div>
</main>
)
}

renderWidget(<TodoApp />)

Type-safe với IDeclareServer

web/src/global.d.ts
import type { IServer as ICustomServer } from '../../server/src'
import type vi from './locales/vi.json'

// Type-safe hooks: TypeScript biết input/output của từng tool
declare module '@v-miniapp/ai/web' {
interface IDeclareServer {
server: ICustomServer
}
}

// Type-safe translations: TypeScript kiểm tra key locale hợp lệ
declare module '@v-miniapp/ui-react' {
interface ICustomLocales {
resource: typeof vi
}
}

Sau khi khai báo, useToolInfo<'list-todos'>(), useCallTool('add-todo'), t('add') đều được TypeScript kiểm tra — sai tên tool hoặc key locale sẽ báo lỗi ngay lúc build.


Skill

Skill list-todos hướng dẫn AI khi nào nên gọi tool này — khi người dùng muốn xem danh sách công việc:

skills/list-todos/SKILL.md
---
name: list-todos
description: Hiển thị toàn bộ danh sách việc cần làm.
metadata:
binding-tools:
- list-todos
---

Sử dụng khi người dùng nói "xem", "liệt kê", "danh sách" việc cần làm,
hoặc hỏi "tôi còn việc gì chưa xong?"

Xem thêm cách khai báo Skill tại Khai báo Skills.