Loading...
Loading...
This skill provides project-specific coding conventions, architectural principles, repository structure standards, testing patterns, and contribution guidelines for the better-chatbot project (https://github.com/cgoinglove/better-chatbot). Use this skill when contributing to or working with better-chatbot to understand the design philosophy and ensure code follows established patterns. Includes: API architecture deep-dive, three-tier tool system (MCP/Workflow/Default), component design patterns, database repository patterns, architectural principles (progressive enhancement, defensive programming, streaming-first), practical templates for adding features (tools, routes, repositories). Use when: working in better-chatbot repository, contributing features/fixes, understanding architectural decisions, following server action validators, implementing tools/workflows, setting up Playwright tests, adding API routes, designing database queries, building UI components, handling multi-AI provider integration Keywords: better-chatbot, chatbot contribution, better-chatbot standards, chatbot development, AI chatbot patterns, API architecture, three-tier tool system, repository pattern, progressive enhancement, defensive programming, streaming-first, compound component pattern, Next.js chatbot, Vercel AI SDK chatbot, MCP tools, workflow builder, server action validators, tool abstraction, DAG workflows, shared business logic, safe() wrapper, tool lifecycle
npx skill4agent add jackspace/claudeskillz better-chatbotbetter-chatbot/
├── src/
│ ├── app/ # Next.js App Router + API routes
│ │ ├── api/[resource]/ # RESTful API organized by domain
│ │ ├── (auth)/ # Auth route group
│ │ ├── (chat)/ # Chat UI route group
│ │ └── store/ # Zustand stores
│ ├── components/ # UI components by domain
│ │ ├── layouts/
│ │ ├── agent/
│ │ ├── chat/
│ │ └── export/
│ ├── lib/ # Core logic and utilities
│ │ ├── action-utils.ts # Server action validators (CRITICAL)
│ │ ├── ai/ # AI integration (models, tools, MCP, speech)
│ │ ├── db/ # Database (Drizzle ORM + repositories)
│ │ ├── validations/ # Zod schemas
│ │ └── [domain]/ # Domain-specific helpers
│ ├── hooks/ # Custom React hooks
│ │ ├── queries/ # Data fetching hooks
│ │ └── use-*.ts
│ └── types/ # TypeScript types by domain
├── tests/ # E2E tests (Playwright)
├── docs/ # Setup guides and tips
├── docker/ # Docker configs
└── drizzle/ # Database migrations/api/[resource]/route.ts → GET/POST collection endpoints
/api/[resource]/[id]/route.ts → GET/PUT/DELETE item endpoints
/api/[resource]/actions.ts → Server actions (mutations)src/app/api/export async function POST(request: Request) {
try {
// 1. Parse and validate request body with Zod
const json = await request.json();
const parsed = zodSchema.parse(json);
// 2. Check authentication
const session = await getSession();
if (!session?.user.id) return new Response("Unauthorized", { status: 401 });
// 3. Check authorization (ownership/permissions)
if (resource.userId !== session.user.id) return new Response("Forbidden", { status: 403 });
// 4. Load/compose dependencies (tools, context, etc.)
const tools = await loadMcpTools({ mentions, allowedMcpServers });
// 5. Execute with streaming if applicable
const stream = createUIMessageStream({ execute: async ({ writer }) => { ... } });
// 6. Return response
return createUIMessageStreamResponse({ stream });
} catch (error) {
logger.error(error);
return Response.json({ message: error.message }, { status: 500 });
}
}src/app/api/chat/shared.chat.tsloadMcpToolsloadWorkFlowToolsloadAppDefaultToolsfilterMCPToolsByMentionsexcludeToolExecutionmergeSystemPrompt// Shared utility function
export const loadMcpTools = (opt?) =>
safe(() => mcpClientsManager.tools())
.map((tools) => {
if (opt?.mentions?.length) {
return filterMCPToolsByMentions(tools, opt.mentions);
}
return filterMCPToolsByAllowedMCPServers(tools, opt?.allowedMcpServers);
})
.orElse({} as Record<string, VercelAIMcpTool>);
// Used in multiple routes
// - /api/chat/route.ts
// - /api/chat/temporary/route.ts
// - /api/workflow/[id]/execute/route.tsts-safe// Returns empty object on failure, chat continues
const MCP_TOOLS = await safe()
.map(errorIf(() => !isToolCallAllowed && "Not allowed"))
.map(() => loadMcpTools({ mentions, allowedMcpServers }))
.orElse({}); // Graceful fallback// In route handler
const stream = createUIMessageStream({
execute: async ({ writer }) => {
// Stream intermediate results
writer.write({ type: "text", content: "Processing..." });
// Execute with streaming
const result = await streamText({
model,
messages,
tools,
onChunk: (chunk) => writer.write({ type: "text-delta", delta: chunk })
});
return { output: result };
}
});
return createUIMessageStreamResponse({ stream });Tier 1: MCP Tools (External)
↓ Can be used in
Tier 2: Workflow Tools (User-Created)
↓ Can be used in
Tier 3: Default Tools (Built-In)src/lib/ai/mcp/// mcp-manager.ts - Singleton for all MCP clients
export const mcpClientsManager = globalThis.__mcpClientsManager__;
// API:
mcpClientsManager.init() // Initialize configured servers
mcpClientsManager.getClients() // Get connected clients
mcpClientsManager.tools() // Get all tools as Vercel AI SDK tools
mcpClientsManager.toolCall(serverId, toolName, args) // Execute tool// MCP tools are tagged with metadata for filtering
type VercelAIMcpTool = Tool & {
_mcpServerId: string;
_originToolName: string;
_toolName: string; // Transformed for AI SDK
};
// Branded type for runtime checking
VercelAIMcpToolTag.create(tool)src/lib/ai/workflow/@workflow_nameenum NodeKind {
Input = "input", // Entry point
LLM = "llm", // AI reasoning
Tool = "tool", // Call MCP/default tools
Http = "http", // HTTP requests
Template = "template",// Text processing
Condition = "condition", // Branching logic
Output = "output", // Exit point
}// Workflows stream intermediate results
executor.subscribe((e) => {
if (e.eventType == "NODE_START") {
dataStream.write({
type: "tool-output-available",
toolCallId,
output: { status: "running", node: e.nodeId }
});
}
if (e.eventType == "NODE_END") {
dataStream.write({
type: "tool-output-available",
toolCallId,
output: { status: "complete", result: e.result }
});
}
});src/lib/ai/tools/export const APP_DEFAULT_TOOL_KIT = {
[AppDefaultToolkit.Visualization]: {
CreatePieChart, CreateBarChart, CreateLineChart,
CreateTable, CreateTimeline
},
[AppDefaultToolkit.WebSearch]: {
WebSearch, WebContent
},
[AppDefaultToolkit.Http]: {
Http
},
[AppDefaultToolkit.Code]: {
JavascriptExecution, PythonExecution
},
};// Execution returns "Success", rendering happens client-side
export const createTableTool = createTool({
description: "Create an interactive table...",
inputSchema: z.object({
title: z.string(),
columns: z.array(...),
data: z.array(...)
}),
execute: async () => "Success"
});
// Client-side rendering in components/tool-invocation/
export function InteractiveTable({ part }) {
const args = part.input;
return <DataTable columns={args.columns} data={args.data} />;
}1. Request → /api/chat/route.ts
2. Parse mentions (@tool, @workflow, @agent)
3. Load tools based on mentions/permissions:
- loadMcpTools() → filters by mentions or allowedMcpServers
- loadWorkFlowTools() → converts workflows to tools
- loadAppDefaultTools() → filters default toolkits
4. Merge all tools into single Record<string, Tool>
5. Handle toolChoice mode:
- "manual" → LLM proposes, user confirms
- "auto" → full execution
- "none" → no tools loaded
6. Pass tools to streamText()
7. Stream results backAppDefaultToolkitcreateTool()APP_DEFAULT_TOOL_KIT@toolnamesrc/components/components/
├── ui/ → shadcn/ui primitives
├── layouts/ → App structure
├── agent/ → Agent-specific
├── workflow/ → Workflow editor
├── tool-invocation/ → Tool result rendering
└── *.tsx → Shared componentsmessage.tsxmessage-parts.tsx// message.tsx exports multiple related components
export function PreviewMessage({ message }) { ... }
export function ErrorMessage({ error }) { ... }
// message-parts.tsx handles polymorphic content
export function MessageParts({ parts }) {
return parts.map(part => {
if (isToolUIPart(part)) return <ToolInvocation part={part} />;
if (part.type === 'text') return <Markdown text={part.text} />;
// ... other types
});
}chat-bot.tsxexport default function ChatBot({ threadId, initialMessages }) {
// 1. State management (Zustand)
const [model, toolChoice] = appStore(useShallow(state => [...]));
// 2. Vercel AI SDK hook
const { messages, append, status } = useChat({
id: threadId,
initialMessages,
body: { chatModel: model, toolChoice },
});
// 3. Render orchestration
return (
<>
<ChatGreeting />
<MessageList messages={messages} />
<PromptInput onSubmit={append} />
</>
);
}lib/ai/tools/components/tool-invocation/// Server-side (lib/ai/tools/create-table.ts)
execute: async (params) => "Success"
// Client-side (components/tool-invocation/interactive-table.tsx)
export function InteractiveTable({ part }) {
const { columns, data } = part.input;
return <DataTable columns={columns} data={data} />;
}src/lib/db/db/
├── repository.ts → Single import point
├── pg/
│ ├── db.pg.ts → Drizzle connection
│ ├── schema.pg.ts → Table definitions
│ └── repositories/ → Feature queries
└── migrations/ → Drizzle migrations// 1. Define interface in src/types/[domain].ts
export type ChatRepository = {
insertThread(thread: Omit<ChatThread, "createdAt">): Promise<ChatThread>;
selectThread(id: string): Promise<ChatThread | null>;
selectThreadDetails(id: string): Promise<ThreadDetails | null>;
};
// 2. Implement in src/lib/db/pg/repositories/[domain]-repository.pg.ts
export const pgChatRepository: ChatRepository = {
selectThreadDetails: async (id: string) => {
const [thread] = await db
.select()
.from(ChatThreadTable)
.leftJoin(UserTable, eq(ChatThreadTable.userId, UserTable.id))
.where(eq(ChatThreadTable.id, id));
if (!thread) return null;
const messages = await pgChatRepository.selectMessagesByThreadId(id);
return {
id: thread.chat_thread.id,
title: thread.chat_thread.title,
userId: thread.chat_thread.userId,
createdAt: thread.chat_thread.createdAt,
userPreferences: thread.user?.preferences,
messages,
};
},
};
// 3. Export from src/lib/db/repository.ts
export const chatRepository = pgChatRepository;export const ChatThreadTable = pgTable("chat_thread", {
id: uuid("id").primaryKey(),
userId: uuid("user_id").references(() => UserTable.id),
}, (table) => ({
userIdIdx: index("chat_thread_user_id_idx").on(table.userId),
}));// Load minimal data
selectThread(id) → { id, title, userId, createdAt }
// Load full data when needed
selectThreadDetails(id) → { ...thread, messages, userPreferences }// Get threads with last message timestamp
const threadsWithActivity = await db
.select({
threadId: ChatThreadTable.id,
lastMessageAt: sql<string>`MAX(${ChatMessageTable.createdAt})`,
})
.from(ChatThreadTable)
.leftJoin(ChatMessageTable, eq(ChatThreadTable.id, ChatMessageTable.threadId))
.groupBy(ChatThreadTable.id)
.orderBy(desc(sql`last_message_at`));# 1. Modify schema in src/lib/db/pg/schema.pg.ts
export const NewTable = pgTable("new_table", { ... });
# 2. Generate migration
pnpm db:generate
# 3. Review generated SQL in drizzle/migrations/
# 4. Apply migration
pnpm db:migrate
# 5. Optional: Visual DB exploration
pnpm db:studioBase Layer: Chat + LLM
↓
Tool Layer: Default + MCP
↓
Composition Layer: Workflows (tools as nodes)
↓
Personalization Layer: Agents (workflows + prompts)instructions.mentionsAppDefaultToolkitNodeKindsafe()const tools = await safe(() => loadMcpTools())
.orElse({}); // Returns default on failurecreateUIMessageStream()// Zod defines runtime validation AND TypeScript types
const schema = z.object({ name: z.string() });
type SchemaType = z.infer<typeof schema>;
// Discriminated unions for polymorphic data
type WorkflowNodeData =
| { kind: "input"; ... }
| { kind: "llm"; ... }
| { kind: "tool"; ... };
// Brand types for runtime checking
VercelAIMcpToolTag.isMaybe(tool)| Want to add... | Extend/Modify... | File Location | Notes |
|---|---|---|---|
| New default tool | | | Add tool implementation in |
| New tool category | | | Creates new toolkit group (e.g., Visualization, WebSearch) |
| New workflow node type | | | Also add UI config in |
| New API endpoint | Create route handler | | Follow standard pattern: auth → validation → repository → response |
| New server action | Use | | Import from |
| New database table | Add to schema + repository | | Then |
| New UI component | Create in domain folder | | Use shadcn/ui primitives from |
| New React hook | Create with | | Data fetching hooks go in |
| New Zod schema | Add to validations | | Use |
| New AI provider | Add to providers registry | | Use |
| New MCP server | Configure via UI | Settings → MCP Servers | No code changes needed (file or DB storage) |
| New agent template | Create via UI | Agents page | Combine tools/workflows/prompts |
| New permission type | Add to permissions enum | | Use in |
| New E2E test | Add test file | | Use Playwright, follow existing patterns |
| New system prompt | Add to prompts | | Use |
1. Define types (src/types/[domain].ts)
2. Create Zod schema (lib/validations/[domain].ts)
3. Add DB table + repository (lib/db/pg/)
4. Create API route (app/api/[resource]/route.ts)
5. Create UI component (components/[domain]/)
6. Create data hook (hooks/queries/use-[resource].ts)
7. Add E2E test (tests/[feature].spec.ts)
8. Run: pnpm check && pnpm test:e2e1. Implement tool (lib/ai/tools/[category]/[name].ts)
2. Add to toolkit (lib/ai/tools/tool-kit.ts)
3. Create rendering component (components/tool-invocation/[name].tsx)
4. Add to tool invocation switch (components/tool-invocation/index.tsx)
5. Test with @toolname mention in chat1. Add NodeKind enum (lib/ai/workflow/workflow.interface.ts)
2. Define node data type (same file)
3. Add executor (lib/ai/workflow/executor/node-executor.ts)
4. Add validator (lib/ai/workflow/validator/node-validate.ts)
5. Create UI config (components/workflow/node-config/[name]-node.tsx)
6. Test in workflow builder@mentions@tool("tool_name")@tool("web-search") find recent AI papers
@tool("create-table") show sales data
@tool("python-execution") calculate fibonacciAPP_DEFAULT_TOOL_KIT@mcp("server_name")@mcp("server_name:tool_name")@mcp("github") create an issue in my repo
@mcp("playwright") navigate to google.com
@mcp("slack:send-message") post update to #general@workflow("workflow_name")@workflow("customer-onboarding") process new signup
@workflow("data-pipeline") transform and analyze CSVinstructions.mentionsUser: @tool("web-search") find AI news, then @tool("create-table") summarize
→ LLM searches → formats results → creates table → returns messageUser: @mcp("github") create issue
→ LLM proposes: create_issue(repo="...", title="...", body="...")
→ User reviews and clicks "Execute"
→ Tool runs → result shownPreset: "Quick Chat"
- Model: GPT-4o-mini (fast)
- Tools: None
- Use for: Rapid Q&A
Preset: "Research Assistant"
- Model: Claude Sonnet 4.5
- Tools: @tool("web-search"), @mcp("wikipedia")
- Use for: Deep research
Preset: "Code Review"
- Model: GPT-5
- Tools: @mcp("github"), @tool("python-execution")
- Use for: Reviewing PRs with tests1. Start chat (no @mentions) → Default tools available
2. Ask: "Search for news about AI"
3. LLM automatically uses @tool("web-search")
4. User sees: Search results → Formatted answer
5. Learns: Tools work automatically in Auto mode1. Create workflow in Workflow Builder:
Input → Web Search → LLM Summary → Output
2. Save as "research-workflow"
3. In chat: "@workflow('research-workflow') AI trends 2025"
4. Sees: Live progress per node
5. Gets: Formatted research report1. Create agent "DevOps Assistant"
2. Agent instructions include: @mcp("github"), @workflow("deploy-pipeline")
3. Select agent from dropdown
4. Chat: "Deploy latest commit to staging"
5. Agent: Uses GitHub MCP → triggers deploy workflow → monitors → reportscreate-tabletool-42// 1. Define in lib/ai/tools/[category]/[tool-name].ts
import { tool as createTool } from "ai";
import { z } from "zod";
export const myNewTool = createTool({
description: "Clear description for LLM to understand when to use this",
inputSchema: z.object({
param: z.string().describe("What this parameter does"),
}),
execute: async (params) => {
// For visualization tools: return "Success"
// For data tools: return actual data
return "Success";
},
});
// 2. Add to lib/ai/tools/tool-kit.ts
import { DefaultToolName } from "./index";
import { myNewTool } from "./[category]/[tool-name]";
export enum DefaultToolName {
// ... existing
MyNewTool = "my_new_tool",
}
export const APP_DEFAULT_TOOL_KIT = {
[AppDefaultToolkit.MyCategory]: {
[DefaultToolName.MyNewTool]: myNewTool,
},
};
// 3. Create rendering in components/tool-invocation/my-tool-invocation.tsx
export function MyToolInvocation({ part }: { part: ToolUIPart }) {
const args = part.input as z.infer<typeof myNewTool.inputSchema>;
return <div>{/* Render based on args */}</div>;
}
// 4. Add to components/tool-invocation/index.tsx switch
if (toolName === DefaultToolName.MyTool) {
return <MyToolInvocation part={part} />;
}// src/app/api/[resource]/route.ts
import { getSession } from "auth/server";
import { [resource]Repository } from "lib/db/repository";
import { z } from "zod";
const querySchema = z.object({
limit: z.coerce.number().default(10),
});
export async function GET(request: Request) {
// 1. Auth check
const session = await getSession();
if (!session?.user.id) {
return new Response("Unauthorized", { status: 401 });
}
// 2. Parse & validate
try {
const url = new URL(request.url);
const params = querySchema.parse(Object.fromEntries(url.searchParams));
// 3. Use repository
const data = await [resource]Repository.selectByUserId(
session.user.id,
params.limit
);
return Response.json(data);
} catch (error) {
if (error instanceof z.ZodError) {
return Response.json(
{ error: "Invalid params", details: error.message },
{ status: 400 }
);
}
console.error("Failed:", error);
return new Response("Internal Server Error", { status: 500 });
}
}
export async function POST(request: Request) {
const session = await getSession();
if (!session?.user.id) {
return new Response("Unauthorized", { status: 401 });
}
try {
const body = await request.json();
const data = createSchema.parse(body);
const item = await [resource]Repository.insert({
...data,
userId: session.user.id,
});
return Response.json(item);
} catch (error) {
if (error instanceof z.ZodError) {
return Response.json({ error: "Invalid input" }, { status: 400 });
}
return Response.json({ error: "Internal error" }, { status: 500 });
}
}// 1. Define interface in src/types/[domain].ts
export type MyRepository = {
selectById(id: string): Promise<MyType | null>;
selectByUserId(userId: string, limit?: number): Promise<MyType[]>;
insert(data: InsertType): Promise<MyType>;
update(id: string, data: Partial<InsertType>): Promise<MyType>;
delete(id: string): Promise<void>;
};
// 2. Add table to src/lib/db/pg/schema.pg.ts
export const MyTable = pgTable("my_table", {
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id").references(() => UserTable.id).notNull(),
name: text("name").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
}, (table) => ({
userIdIdx: index("my_table_user_id_idx").on(table.userId),
}));
// 3. Implement in src/lib/db/pg/repositories/my-repository.pg.ts
import { pgDb as db } from "../db.pg";
import { MyTable } from "../schema.pg";
import { eq, desc } from "drizzle-orm";
export const pgMyRepository: MyRepository = {
selectById: async (id) => {
const [result] = await db
.select()
.from(MyTable)
.where(eq(MyTable.id, id));
return result ?? null;
},
selectByUserId: async (userId, limit = 10) => {
return await db
.select()
.from(MyTable)
.where(eq(MyTable.userId, userId))
.orderBy(desc(MyTable.createdAt))
.limit(limit);
},
insert: async (data) => {
const [result] = await db
.insert(MyTable)
.values(data)
.returning();
return result;
},
update: async (id, data) => {
const [result] = await db
.update(MyTable)
.set(data)
.where(eq(MyTable.id, id))
.returning();
return result;
},
delete: async (id) => {
await db.delete(MyTable).where(eq(MyTable.id, id));
},
};
// 4. Export from src/lib/db/repository.ts
export { pgMyRepository as myRepository } from "./pg/repositories/my-repository.pg";
// 5. Generate and run migration
// pnpm db:generate
// pnpm db:migratelib/action-utils.ts// Pattern 1: Simple validation
validatedAction(schema, async (data, formData) => { ... })
// Pattern 2: With user context (auto-auth, auto-error handling)
validatedActionWithUser(schema, async (data, formData, user) => { ... })
// Pattern 3: Permission-based (admin, user-manage)
validatedActionWithAdminPermission(schema, async (data, formData, session) => { ... })// Branded types for runtime type narrowing
VercelAIMcpToolTag.create(tool) // Brand as MCP tool
VercelAIWorkflowToolTag.isMaybe(tool) // Check if Workflow tool
// Single handler for multiple tool types
if (VercelAIWorkflowToolTag.isMaybe(tool)) {
// Workflow-specific logic
} else if (VercelAIMcpToolTag.isMaybe(tool)) {
// MCP-specific logic
}dataStream.write()| Type | Convention | Example |
|---|---|---|
| Components | PascalCase | |
| Component files | kebab-case or PascalCase | |
| Hooks | camelCase with | |
| Utilities | camelCase | |
| API routes | Next.js convention | |
| Types | Domain suffix | |
const schema = z.object({ name: z.string() })
type SchemaType = z.infer<typeof schema>src/types/enum UpdateUserPasswordError {
INVALID_CURRENT_PASSWORD = "invalid_current_password",
PASSWORD_MISMATCH = "password_mismatch"
}superRefine.superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({ path: ["confirmPassword"], message: "Passwords must match" })
}
})# Development
pnpm dev # Start dev server
pnpm build # Production build
pnpm start # Start production server
pnpm lint:fix # Auto-fix linting issues
# Database (Drizzle ORM)
pnpm db:generate # Generate migrations
pnpm db:migrate # Run migrations
pnpm db:push # Push schema changes
pnpm db:studio # Open Drizzle Studio
# Testing
pnpm test # Run Vitest unit tests
pnpm test:e2e # Full Playwright E2E suite
pnpm test:e2e:first-user # First-user signup + admin role tests
pnpm test:e2e:standard # Standard tests (skip first-user)
pnpm test:e2e:ui # Interactive Playwright UI
# Quality Check
pnpm check # Run lint + type-check + tests.env.example.envpnpm ifeat/feature-namefix/bug-name*.test.ts// src/lib/utils.test.ts
import { describe, it, expect } from 'vitest'
import { formatDate } from './utils'
describe('formatDate', () => {
it('formats ISO date correctly', () => {
expect(formatDate('2025-01-01')).toBe('January 1, 2025')
})
it('handles invalid date', () => {
expect(formatDate('invalid')).toBe('Invalid Date')
})
})# First-user tests (clean DB → signup → verify admin role)
pnpm test:e2e:first-user
# Standard tests (assumes first user exists)
pnpm test:e2e:standard
# Full suite (first-user → standard)
pnpm test:e2efeat: Add realtime voice chat
fix: Resolve MCP tool streaming error
chore: Update dependencies
docs: Add OAuth setup guidefeat:fix:chore:docs:style:refactor:test:perf:build:# Must pass before PR:
pnpm check # Lint + type-check + tests
pnpm test:e2e # E2E tests (if applicable)validatedActionWithUservalidatedActionWithAdminPermissionsuperRefinepnpm checkpnpm checkvalidatedActionWithUservalidatedActionWithAdminPermission// ❌ BAD: Manual auth check
export async function updateProfile(data: ProfileData) {
const session = await getSession()
if (!session) throw new Error("Unauthorized")
// ... rest of logic
}
// ✅ GOOD: Use validator
export const updateProfile = validatedActionWithUser(
profileSchema,
async (data, formData, user) => {
// user is guaranteed to exist, auto-error handling
}
)// ❌ BAD: Assuming tool type
const result = await executeMcpTool(tool)
// ✅ GOOD: Check tool type
if (VercelAIMcpToolTag.isMaybe(tool)) {
const result = await executeMcpTool(tool)
} else if (VercelAIWorkflowToolTag.isMaybe(tool)) {
const result = await executeWorkflowTool(tool)
}// ❌ BAD: Manual parsing
const name = formData.get("name") as string
if (!name) throw new Error("Name required")
// ✅ GOOD: Validator with Zod
const schema = z.object({ name: z.string().min(1) })
export const action = validatedAction(schema, async (data) => {
// data.name is validated and typed
})superRefine// ❌ BAD: Separate checks
if (data.password !== data.confirmPassword) { /* error */ }
// ✅ GOOD: Zod superRefine
const schema = z.object({
password: z.string(),
confirmPassword: z.string()
}).superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
path: ["confirmPassword"],
message: "Passwords must match"
})
}
})// ❌ BAD: Deep mutation
store.workflow.nodes[0].status = "complete"
// ✅ GOOD: Shallow update
set(state => ({
workflow: {
...state.workflow,
nodes: state.workflow.nodes.map((node, i) =>
i === 0 ? { ...node, status: "complete" } : node
)
}
}))# ❌ BAD: Running standard tests on clean DB
pnpm test:e2e:standard
# ✅ GOOD: Full suite with first-user setup
pnpm test:e2e.env.example.env.envpnpm i# Auto-generates .env on install
pnpm i
# Verify all required vars present
# Required: DATABASE_URL, at least one LLM_API_KEY# ❌ BAD:
git commit -m "added feature"
git commit -m "fix bug"
# ✅ GOOD:
git commit -m "feat: add MCP tool streaming"
git commit -m "fix: resolve auth redirect loop"import { validatedActionWithUser } from "@/lib/action-utils"
import { z } from "zod"
const updateProfileSchema = z.object({
name: z.string().min(1),
email: z.string().email()
})
export const updateProfile = validatedActionWithUser(
updateProfileSchema,
async (data, formData, user) => {
// user is guaranteed authenticated
// data is validated and typed
await db.update(users).set(data).where(eq(users.id, user.id))
return { success: true }
}
)import { VercelAIMcpToolTag, VercelAIWorkflowToolTag } from "@/lib/ai/tools"
async function executeTool(tool: unknown) {
if (VercelAIMcpToolTag.isMaybe(tool)) {
return await executeMcpTool(tool)
} else if (VercelAIWorkflowToolTag.isMaybe(tool)) {
return await executeWorkflowTool(tool)
} else {
return await executeDefaultTool(tool)
}
}import { useWorkflowStore } from "@/app/store/workflow"
// In component:
const updateNodeStatus = useWorkflowStore(state => state.updateNodeStatus)
// In store:
updateNodeStatus: (nodeId, status) =>
set(state => ({
workflow: {
...state.workflow,
nodes: state.workflow.nodes.map(node =>
node.id === nodeId ? { ...node, status } : node
)
}
}))references/AGENTS.mdreferences/CONTRIBUTING.mdpnpm i.envpnpm devpnpm checkpnpm test:e2ereferences/AGENTS.mdreferences/CONTRIBUTING.md