Lesson 12 / 14
12. Travel Agent from Scratch: blueprint
Bringing it all together. One real project. One monorepo. Specific files, configs, structure. From here on, you copy-paste and adapt.
Bringing it all together. One real project. One monorepo. Concrete files, configs, structure. From here you copy-paste and adapt.
12.1. What we’re building
Travel Agent — an AI service that helps plan a trip. Architecturally:
In development we use Claude Code (CLI) to write code, tests, migrations. At runtime our backend (apps/api) talks to the Anthropic API directly via SDK, reusing the same MCP servers.
12.2. Repository structure
travel-agent/
├── .claude/ # ВСЁ для Claude Code разработки
│ ├── settings.json # permissions, hooks
│ ├── settings.local.json # ваши локальные tweaks (gitignored)
│ ├── agents/
│ │ ├── trip-architect.md
│ │ ├── code-reviewer.md
│ │ ├── policy-checker.md
│ │ └── cost-analyzer.md
│ ├── skills/
│ │ ├── backend-add-route/SKILL.md
│ │ ├── backend-add-migration/SKILL.md
│ │ ├── frontend-add-page/SKILL.md
│ │ ├── frontend-add-component/SKILL.md
│ │ ├── mcp-add-server/SKILL.md
│ │ ├── prepare-pr/SKILL.md
│ │ ├── debug-mcp-failure/SKILL.md
│ │ └── release-deploy/SKILL.md
│ ├── hooks/
│ │ ├── format.sh
│ │ ├── no-secrets.sh
│ │ ├── bash-guard.sh
│ │ └── notify-stop.sh
│ └── commands/
│ └── trip-status.md # /trip-status slash command
├── CLAUDE.md # глобальный контекст репо
├── CLAUDE.local.md # ваши заметки (gitignored)
├── .mcp.json # MCP-серверы для CC и для прода
├── apps/
│ ├── web/
│ │ ├── CLAUDE.md # frontend-специфика
│ │ ├── src/
│ │ ├── package.json
│ │ └── vite.config.ts
│ └── api/
│ ├── CLAUDE.md # backend-специфика
│ ├── src/
│ │ ├── index.ts # Hono entry
│ │ ├── routes/
│ │ ├── services/
│ │ ├── agent/ # Anthropic SDK + MCP-клиенты
│ │ ├── db/
│ │ └── mcp/ # обвязка для подключения MCP
│ └── package.json
├── packages/
│ ├── mcp-flights/
│ │ ├── CLAUDE.md
│ │ └── src/server.ts
│ ├── mcp-hotels/
│ ├── mcp-weather/
│ ├── mcp-docs/
│ └── shared/ # types, zod schemas
├── docs/
│ ├── adr/
│ ├── architecture/
│ │ ├── backend-stack.md
│ │ ├── frontend-stack.md
│ │ └── mcp-servers.md
│ └── dev/
│ ├── commands.md
│ ├── precommit-checklist.md
│ └── runbook.md
├── infra/
│ ├── docker-compose.yml
│ └── k8s/
├── scripts/
│ ├── seed-db.ts
│ └── route-template.ts # шаблон для skill backend-add-route
├── package.json # workspaces
├── pnpm-workspace.yaml
├── tsconfig.base.json
├── .env.example
└── .gitignore
12.3. Root CLAUDE.md (ready template)
🔧 ./CLAUDE.md:
# Travel Agent
AI-сервис планирования путешествий. Backend на Node + Hono + TypeScript, frontend на React + Vite + TypeScript, агент использует Anthropic SDK + 4 MCP-сервера (Amadeus / Duffel / OpenMeteo / wiki).
## Структура
travel-agent/ ├── apps/web/ — React SPA ├── apps/api/ — Hono backend (entry in src/index.ts) ├── packages/mcp-*/ — MCP servers ├── packages/shared/ — shared types/zod schemas (workspace: @travel/shared) ├── infra/ — docker-compose, k8s └── docs/ — ADR, architecture, dev guides
## Команды
- `pnpm dev` — поднять api + web одновременно (turbo)
- `pnpm dev:api`, `pnpm dev:web` — раздельно
- `pnpm test` — vitest для всех пакетов
- `pnpm test --filter=@travel/api` — только api
- `pnpm typecheck` — tsc --noEmit по всему монорепо
- `pnpm lint` — eslint + prettier --check
- `pnpm db:migrate` — drizzle migrations
- `pnpm seed` — заполнить тестовыми данными
- `pnpm build` — продакшн-сборка
- `pnpm dev:mcp:flights` — поднять только mcp-flights отдельно
## Соглашения
- TypeScript strict, никаких `any`. `unknown` + type guard.
- React: только функциональные компоненты + hooks. Без классов.
- Backend routers в `apps/api/src/routes/`, бизнес-логика в `apps/api/src/services/`.
- Database: drizzle ORM, никакого raw SQL.
- Логирование: pino через `@travel/shared/logger`. Никакого `console.*`.
- MCP-серверы следуют шаблону `src/server.ts` экспортирует `createServer()`.
- Импортируем через workspace alias: `@travel/shared`, `@travel/mcp-flights`.
## Запреты
- Не редактируй `apps/api/src/legacy/` — старый код, удаляется.
- Не используй `console.*` — везде `logger`.
- Не пиши raw SQL — drizzle query builder.
- Не коммить `.env`, `secrets/*`, `*.local.json`.
## Перед коммитом
@docs/dev/precommit-checklist.md
## Архитектура подсистем
@docs/architecture/backend-stack.md
@docs/architecture/frontend-stack.md
@docs/architecture/mcp-servers.md
12.4. .claude/settings.json (ready)
🔧 ./.claude/settings.json:
{
"$schema": "https://claude.com/schemas/settings.json",
"permissions": {
"allow": [
"Read",
"Grep",
"Glob",
"WebFetch",
"WebSearch",
"Bash(pnpm test*)",
"Bash(pnpm typecheck)",
"Bash(pnpm lint*)",
"Bash(pnpm build*)",
"Bash(pnpm dev:*)",
"Bash(git status)",
"Bash(git diff*)",
"Bash(git log*)",
"Bash(git branch*)",
"Bash(gh pr*)",
"Bash(gh issue*)",
"Edit(apps/**)",
"Edit(packages/**)",
"Edit(docs/**)",
"Edit(scripts/**)",
"Write(apps/**)",
"Write(packages/**)",
"Write(docs/**)",
"Write(scripts/**)",
"mcp__flights__*",
"mcp__hotels__*",
"mcp__weather__*",
"mcp__docs__*"
],
"ask": [
"Bash(pnpm db:migrate*)",
"Bash(pnpm seed*)",
"Bash(git push*)",
"Bash(git commit*)",
"Bash(gh pr create*)",
"Edit(packages/shared/**)",
"Edit(.claude/**)"
],
"deny": [
"Edit(.env*)",
"Edit(secrets/**)",
"Edit(infra/k8s/secrets/**)",
"Bash(rm -rf *)",
"Bash(curl * | sh)",
"Bash(curl * | bash)",
"Bash(NODE_ENV=production *)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/no-secrets.sh"
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/bash-guard.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/format.sh"
}
]
}
],
"Stop": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/notify-stop.sh",
"timeout": 10
}
]
}
]
},
"model": "sonnet",
"outputStyle": "default"
}
12.5. .mcp.json (ready, both for CC and production agent)
🔧 ./.mcp.json:
{
"mcpServers": {
"flights": {
"type": "stdio",
"command": "node",
"args": ["packages/mcp-flights/dist/server.js"],
"env": {
"AMADEUS_API_KEY": "${AMADEUS_API_KEY}",
"AMADEUS_API_SECRET": "${AMADEUS_API_SECRET}",
"AMADEUS_BASE_URL": "${AMADEUS_BASE_URL:-https://test.api.amadeus.com}"
}
},
"hotels": {
"type": "stdio",
"command": "node",
"args": ["packages/mcp-hotels/dist/server.js"],
"env": {
"DUFFEL_API_KEY": "${DUFFEL_API_KEY}"
}
},
"weather": {
"type": "stdio",
"command": "node",
"args": ["packages/mcp-weather/dist/server.js"]
},
"docs": {
"type": "stdio",
"command": "node",
"args": ["packages/mcp-docs/dist/server.js"],
"env": {
"DOCS_INDEX_PATH": "./docs"
}
}
}
}
12.6. Minimal set of skills (with examples)
Full list — in 04-skills.md. Here — a diagram of when each one triggers:
🔧 .claude/skills/backend-add-migration/SKILL.md:
---
name: backend-add-migration
description: Add a drizzle database migration for the Travel Agent Postgres schema. Use when user asks to "add migration", "alter table", "create table", "add column", "rename field", or any DB schema change.
allowed-tools: ["Read", "Write", "Edit", "Bash(pnpm db:*)", "Grep"]
model: sonnet
effort: medium
---
# Add migration
Procedure for safely adding a database migration.
## Steps
1. Read `apps/api/src/db/schema.ts` to understand current schema.
2. Modify schema.ts to reflect the desired change (use drizzle types).
3. Run `pnpm db:generate` to scaffold a migration file in `apps/api/src/db/migrations/`.
4. Inspect the generated SQL — confirm:
- No `DROP TABLE` / `DROP COLUMN` unless user explicitly approved.
- For `NOT NULL` adds — provide a default or backfill strategy.
- For renames — drizzle generates DROP + CREATE; manually edit to use `RENAME` if data preservation needed.
5. Update repository layer in `apps/api/src/db/repositories/` if needed.
6. Update affected services if shape changed.
7. Add at least one test in `apps/api/test/db/<migration-name>.test.ts`.
8. Run `pnpm typecheck && pnpm test --filter=@travel/api`.
## Verification
- Migration applied locally: `pnpm db:migrate`.
- All tests pass.
- Schema diff matches intent.
## References
- @docs/architecture/db.md
- @scripts/migration-checklist.md
🔧 .claude/skills/mcp-add-server/SKILL.md:
---
name: mcp-add-server
description: Scaffold a new MCP server in the packages/mcp-* structure following Travel Agent conventions. Use when user asks to "add MCP", "create new MCP server", "add integration with X via MCP".
allowed-tools: ["Read", "Write", "Edit", "Bash"]
model: sonnet
effort: medium
---
# Scaffold MCP server
## Inputs
- Server name (e.g. "trains") → directory `packages/mcp-trains`.
- Provider URL (the upstream API).
- Auth type (API key / OAuth / none).
## Steps
1. Copy template:
```bash
cp -r packages/_template-mcp packages/mcp-{name}
```
- Update
packages/mcp-{name}/package.json— name, deps. - Implement provider client in
src/{name}.ts(use undici). - Define tools in
src/server.ts:{name}_search— search.{name}_get_details— details.- If needed —
{name}_book,{name}_cancel.
- Each tool: zod schema → JSON Schema → register in
setRequestHandler(ListToolsRequestSchema, ...). - Build:
pnpm --filter=@travel/mcp-{name} build. - Add server to
.mcp.json(stdio config). - Add to
apps/api/src/mcp/config.tsso production agent picks it up too. - Add a smoke test in
packages/mcp-{name}/test/smoke.test.ts.
Verification
claude mcp test {name}returns OK.pnpm --filter=@travel/mcp-{name} testpasses./mcpshows the new server.
References
- @docs/architecture/mcp-servers.md
- @packages/_template-mcp/README.md
---
## 12.7. Полный agents/ набор
Полные frontmatter в [09-subagents](./09-subagents). Карта по ролям:
| Агент | Модель | Tools | Когда зовут |
|-------|--------|-------|-------------|
| `trip-architect` | opus | Read + flights/hotels/weather MCP | Многосоставные маршруты |
| `code-reviewer` | sonnet | Read + Grep + git/gh Bash | Перед мержем |
| `policy-checker` | haiku | Read | Проверка регуляторных требований (GDPR, кэшинг данных) |
| `cost-analyzer` | sonnet | Read + flights/hotels MCP | Сверка цен по нескольким датам/направлениям |
---
## 12.8. Production agent (apps/api/src/agent)
🔧 `apps/api/src/agent/index.ts`:
```typescript
import Anthropic from "@anthropic-ai/sdk";
import { connectAllMcp } from "../mcp/connect.js";
import { logger } from "@travel/shared/logger";
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
const SYSTEM_PROMPT = `Ты — Travel Agent. Помогаешь планировать поездки.
Используй tools для поиска рейсов, отелей, погоды, проверки правил.
Возвращай ответы на языке пользователя. Будь лаконичен.`;
export async function* chatStream(
userMessage: string,
history: Anthropic.MessageParam[],
signal: AbortSignal
) {
const mcp = await connectAllMcp();
const tools = await mcp.buildAnthropicTools();
const messages = [...history, { role: "user", content: userMessage } as const];
outer: while (true) {
const stream = await anthropic.messages.stream(
{
model: "claude-opus-4-7",
max_tokens: 4096,
system: [
{ type: "text", text: SYSTEM_PROMPT, cache_control: { type: "ephemeral" } },
],
tools,
messages,
},
{ signal }
);
let finalMessage: Anthropic.Message | null = null;
for await (const event of stream) {
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
yield { type: "text", value: event.delta.text };
}
if (event.type === "message_stop") {
finalMessage = await stream.finalMessage();
}
}
if (!finalMessage) break outer;
if (finalMessage.stop_reason === "end_turn") {
messages.push({ role: "assistant", content: finalMessage.content });
yield { type: "done", message: finalMessage };
break outer;
}
if (finalMessage.stop_reason === "tool_use") {
messages.push({ role: "assistant", content: finalMessage.content });
const toolResults: Anthropic.ToolResultBlockParam[] = [];
// Параллельно исполняем все tool_use из этого turn
const calls = finalMessage.content.filter(
(b): b is Anthropic.ToolUseBlock => b.type === "tool_use"
);
const results = await Promise.allSettled(
calls.map(async (block) => {
yield_event({ type: "tool_call", name: block.name, input: block.input });
try {
const result = await mcp.callTool(block.name, block.input as Record<string, unknown>);
return { id: block.id, result };
} catch (err) {
logger.warn({ err, name: block.name }, "MCP tool error");
return { id: block.id, result: { error: String(err) } };
}
})
);
for (const r of results) {
if (r.status === "fulfilled") {
toolResults.push({
type: "tool_result",
tool_use_id: r.value.id,
content: JSON.stringify(r.value.result),
});
}
}
messages.push({ role: "user", content: toolResults });
continue outer;
}
break outer;
}
}
function yield_event(_e: unknown) {
// helper if you wire SSE callbacks separately
}
🔧 apps/api/src/mcp/connect.ts:
import { ClientSession } from "@modelcontextprotocol/sdk/client/session.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { readFile } from "node:fs/promises";
type McpConfig = {
mcpServers: Record<
string,
{ type: "stdio"; command: string; args: string[]; env?: Record<string, string> }
>;
};
export async function connectAllMcp() {
const cfg = JSON.parse(await readFile(".mcp.json", "utf8")) as McpConfig;
const sessions: Record<string, ClientSession> = {};
for (const [name, server] of Object.entries(cfg.mcpServers)) {
if (server.type !== "stdio") continue;
const env = { ...process.env, ...substituteEnv(server.env ?? {}) };
const transport = new StdioClientTransport({ command: server.command, args: server.args, env });
const session = new ClientSession(transport);
await session.initialize();
sessions[name] = session;
}
async function buildAnthropicTools() {
const all: Anthropic.Tool[] = [];
for (const [name, session] of Object.entries(sessions)) {
const list = await session.listTools();
for (const t of list.tools) {
all.push({
name: `mcp__${name}__${t.name}`,
description: t.description ?? "",
input_schema: t.inputSchema as any,
});
}
}
return all;
}
async function callTool(fullName: string, args: Record<string, unknown>) {
const [, server, tool] = fullName.split("__");
const session = sessions[server];
if (!session) throw new Error(`unknown server: ${server}`);
return session.callTool({ name: tool, arguments: args });
}
return { sessions, buildAnthropicTools, callTool };
}
function substituteEnv(record: Record<string, string>): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(record)) {
out[k] = v.replace(/\$\{([A-Z0-9_]+)(?::-([^}]+))?\}/g, (_, name, def) => {
return process.env[name] ?? def ?? "";
});
}
return out;
}
import type Anthropic from "@anthropic-ai/sdk";
12.9. A day in the life of a developer
Scenario: “add a route sharing feature with short links”.
12.10. What to commit to the repo, what not to
✅ In the repo:
CLAUDE.md.claude/settings.json.claude/agents/*.md.claude/skills/**/SKILL.md+ scripts/references/templates.claude/hooks/**(scripts).claude/commands/*.md.mcp.json
❌ NOT in the repo (.gitignore):
CLAUDE.local.md.claude/settings.local.json.mcp.local.json.env*(except.env.example)
12.11. Bootstrap a new developer
When a new developer clones the repo:
git clone git@github.com:travel-agent/monorepo
cd monorepo
pnpm install
cp .env.example .env.local
# Заполнить ANTHROPIC_API_KEY, AMADEUS_*, DUFFEL_*
pnpm build:mcp # компилируем все MCP-серверы
claude # стартует CC, подхватывает CLAUDE.md, settings, MCP
# /context — увидеть стартовое заполнение
# /skills — список всех скиллов
# /agents — список всех агентов
# /mcp — статус MCP-серверов
# Готов к работе. Первая задача:
# /init # обновить корневой CLAUDE.md под свой setup, если нужно
12.12. Optional step: package everything into a plugin
If your company is going to build multiple travel projects on this stack, it makes sense to extract .claude/ + .mcp.json into a plugin travel-stack-toolkit (see 07-plugins).
Then in a new project:
/plugin install github:travel-agent/claude-plugin@v0.4.0
And all the infrastructure (skills, agents, hooks, default MCP config) appears with one command.