better-chatbot — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited better-chatbot (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Status: Production Ready Last Updated: 2025-11-04 (v2.1.0 - Added extension points + UX patterns) Dependencies: None (references better-chatbot project) Latest Versions: Next.js 15.3.2, Vercel AI SDK 5.0.82, Better Auth 1.3.34, Drizzle ORM 0.41.0
better-chatbot is an open-source AI chatbot platform for individuals and teams, built with Next.js 15 and Vercel AI SDK v5. It combines multi-model AI support (OpenAI, Anthropic, Google, xAI, Ollama, OpenRouter) with advanced features like MCP (Model Context Protocol) tool integration, visual workflow builder, realtime voice assistant, and team collaboration.
This skill teaches Claude the project-specific conventions and patterns used in better-chatbot to ensure contributions follow established standards and avoid common pitfalls.
better-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 migrationsConvention: RESTful resources with Next.js App Router conventions
/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)Location: src/app/api/
Template structure:
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 });
}
}Key Insight: Extract complex orchestration logic into shared utilities
Example: src/app/api/chat/shared.chat.ts
This file demonstrates how to handle:
loadMcpTools, loadWorkFlowTools, loadAppDefaultTools)filterMCPToolsByMentions, excludeToolExecution)mergeSystemPrompt)Pattern:
// 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.tsWhy: DRY principle, single source of truth, consistent behavior
Library: ts-safe for functional error handling
Philosophy: Never crash the chat - degrade features gracefully
// Returns empty object on failure, chat continues
const MCP_TOOLS = await safe()
.map(errorIf(() => !isToolCallAllowed && "Not allowed"))
.map(() => loadMcpTools({ mentions, allowedMcpServers }))
.orElse({}); // Graceful fallbackPattern: Use Vercel AI SDK streaming utilities
// 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 });Why: Live feedback, better UX, handles long-running operations
Design Goal: Balance extensibility (MCP), composability (workflows), and batteries-included (default tools)
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)Location: src/lib/ai/mcp/
Philosophy: Model Context Protocol servers become first-class tools
Manager Pattern:
// 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 toolWhy Global Singleton?
Tool Wrapping:
// 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)Location: src/lib/ai/workflow/
Philosophy: Visual workflows become callable tools via @workflow_name
Node Types:
enum 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
}Execution with Streaming:
// 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 }
});
}
});Key Feature: Live progress updates in chat UI
Location: src/lib/ai/tools/
Categories:
export const APP_DEFAULT_TOOL_KIT = {
[AppDefaultToolkit.Visualization]: {
CreatePieChart, CreateBarChart, CreateLineChart,
CreateTable, CreateTimeline
},
[AppDefaultToolkit.WebSearch]: {
WebSearch, WebContent
},
[AppDefaultToolkit.Http]: {
Http
},
[AppDefaultToolkit.Code]: {
JavascriptExecution, PythonExecution
},
};Tool Implementation Pattern:
// 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} />;
}Why Separation?
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 backAdding a new tool type is simple:
AppDefaultToolkitcreateTool()APP_DEFAULT_TOOL_KIT@toolnameLocation: src/components/
components/
├── ui/ → shadcn/ui primitives
├── layouts/ → App structure
├── agent/ → Agent-specific
├── workflow/ → Workflow editor
├── tool-invocation/ → Tool result rendering
└── *.tsx → Shared componentsPrinciple: Group by feature, not by type
Example: message.tsx + message-parts.tsx
Philosophy: Break complex components into composable parts
// 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
});
}Example: chat-bot.tsx
Structure:
export 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} />
</>
);
}Why: Top-level orchestrates, delegates rendering to specialized components
Key Architecture Decision:
lib/ai/tools/components/tool-invocation/Example:
// 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} />;
}Benefits:
Location: src/lib/db/
Structure:
db/
├── repository.ts → Single import point
├── pg/
│ ├── db.pg.ts → Drizzle connection
│ ├── schema.pg.ts → Table definitions
│ └── repositories/ → Feature queries
└── migrations/ → Drizzle migrationsPhilosophy: Abstract DB behind repository interfaces
Pattern:
// 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;Why:
1. Indexes on Foreign Keys:
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),
}));2. Selective Loading:
// Load minimal data
selectThread(id) → { id, title, userId, createdAt }
// Load full data when needed
selectThreadDetails(id) → { ...thread, messages, userPreferences }3. SQL Aggregation:
// 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:studioFeatures build in layers:
Base Layer: Chat + LLM
↓
Tool Layer: Default + MCP
↓
Composition Layer: Workflows (tools as nodes)
↓
Personalization Layer: Agents (workflows + prompts)Evidence:
instructions.mentions (inject tools/workflows)User Journey:
New Tool?
AppDefaultToolkit enum → auto-availableNew Workflow Node?
NodeKind enum → executor handles itNew MCP Server?
Use `safe()` everywhere:
const tools = await safe(() => loadMcpTools())
.orElse({}); // Returns default on failurePhilosophy: Never crash the chat - degrade gracefully
Evidence:
createUIMessageStream()Why: Live feedback, better UX, handles long operations
Pattern:
// 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)Quick lookup: "I want to add X" → "Modify Y file"
| Want to add... | Extend/Modify... | File Location | Notes |
|---|---|---|---|
| New default tool | AppDefaultToolkit enum + APP_DEFAULT_TOOL_KIT | lib/ai/tools/tool-kit.ts | Add tool implementation in lib/ai/tools/[category]/ + rendering in components/tool-invocation/ |
| New tool category | AppDefaultToolkit enum | lib/ai/tools/index.ts | Creates new toolkit group (e.g., Visualization, WebSearch) |
| New workflow node type | NodeKind enum + executor + validator | lib/ai/workflow/workflow.interface.ts + executor/node-executor.ts + validator/node-validate.ts | Also add UI config in components/workflow/node-config/ |
| New API endpoint | Create route handler | src/app/api/[resource]/route.ts | Follow standard pattern: auth → validation → repository → response |
| New server action | Use validatedActionWithUser | src/app/api/[resource]/actions.ts | Import from lib/action-utils.ts |
| New database table | Add to schema + repository | lib/db/pg/schema.pg.ts + lib/db/pg/repositories/[name]-repository.pg.ts | Then pnpm db:generate and pnpm db:migrate |
| New UI component | Create in domain folder | src/components/[domain]/[name].tsx | Use shadcn/ui primitives from components/ui/ |
| New React hook | Create with use- prefix | src/hooks/use-[name].ts or src/hooks/queries/use-[name].ts | Data fetching hooks go in queries/ subfolder |
| New Zod schema | Add to validations | src/lib/validations/[domain].ts | Use z.infer<typeof schema> for TypeScript types |
| New AI provider | Add to providers registry | lib/ai/providers.ts | Use createOpenAI, createAnthropic, etc. from AI SDK |
| 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 | lib/auth/permissions.ts | Use in validatedActionWithAdminPermission |
| New E2E test | Add test file | tests/[feature].spec.ts | Use Playwright, follow existing patterns |
| New system prompt | Add to prompts | lib/ai/prompts.ts | Use mergeSystemPrompt for composition |
Adding a Feature End-to-End:
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:e2eAdding a Tool End-to-End:
1. 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 chatAdding a Workflow Node End-to-End:
1. 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 builderCore Design Principle: Every feature is instantly accessible via @mentions - no digging through menus.
Why This Matters: Users can compose features on-the-fly without context switching.
#### 1. @tool (Default Tools) Format: @tool("tool_name")
Examples:
@tool("web-search") find recent AI papers
@tool("create-table") show sales data
@tool("python-execution") calculate fibonacciHow It Works:
APP_DEFAULT_TOOL_KITUse Case: Built-in capabilities (search, visualization, code execution)
#### 2. @mcp (MCP Server Tools) Format: @mcp("server_name") or specific tool @mcp("server_name:tool_name")
Examples:
@mcp("github") create an issue in my repo
@mcp("playwright") navigate to google.com
@mcp("slack:send-message") post update to #generalHow It Works:
Use Case: External integrations (GitHub, Slack, databases, etc.)
#### 3. @workflow (Custom Workflows) Format: @workflow("workflow_name")
Examples:
@workflow("customer-onboarding") process new signup
@workflow("data-pipeline") transform and analyze CSVHow It Works:
Use Case: Multi-step automations, business processes
#### 4. @agent (Agent Personas) Format: Select agent from dropdown (not typed in message)
How It Works:
instructions.mentions auto-inject tools/workflowsUse Case: Role-specific contexts (coding assistant, data analyst, etc.)
Context: User selects mode from dropdown
#### Auto Mode (Default)
Example Flow:
User: @tool("web-search") find AI news, then @tool("create-table") summarize
→ LLM searches → formats results → creates table → returns message#### Manual Mode
Example Flow:
User: @mcp("github") create issue
→ LLM proposes: create_issue(repo="...", title="...", body="...")
→ User reviews and clicks "Execute"
→ Tool runs → result shown#### None Mode
What Are Presets?
Example Use Cases:
Preset: "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 testsHow To Create:
#### Beginner: First-Time User
1. 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 mode#### Intermediate: Using Workflows
1. 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 report#### Advanced: Agent + MCP + Workflows
1. 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 → reports1. Discoverability
create-table not tool-42)2. Composability
3. Progressive Disclosure
4. Feedback
// 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)Centralized pattern for validated, permission-gated server actions:
// 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) => { ... })Prevents:
2. Tool Abstraction System Unified interface for multiple tool types using branded type tags:
// 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
}Tool Types:
3. Workflow Execution Engine DAG-based workflow system with real-time streaming:
dataStream.write()4. State Management Zustand stores with shallow comparison for workflows and app config.
| Type | Convention | Example |
|---|---|---|
| Components | PascalCase | ChatBot.tsx, WorkflowBuilder.tsx |
| Component files | kebab-case or PascalCase | chat-bot.tsx, ChatBot.tsx |
| Hooks | camelCase with use- prefix | use-chat-bot.ts, use-workflow.ts |
| Utilities | camelCase | action-utils.ts, shared.chat.ts |
| API routes | Next.js convention | src/app/api/[resource]/route.ts |
| Types | Domain suffix | chat.ts, mcp.ts, workflow.ts |
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 to .env (auto-generated on pnpm i)feat/feature-name or fix/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')
})
})Special orchestration for multi-user and first-user scenarios:
# 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:e2eTest project dependencies ensure sequenced execution:
Shared auth states across test runs to avoid re-login.
Seed/cleanup scripts for deterministic testing.
Major changes require discussion first:
No prior approval needed:
Title format (Conventional Commits):
feat: Add realtime voice chat
fix: Resolve MCP tool streaming error
chore: Update dependencies
docs: Add OAuth setup guidePrefixes: feat:, fix:, chore:, docs:, style:, refactor:, test:, perf:, build:
Visual documentation required:
Description should explain:
# Must pass before PR:
pnpm check # Lint + type-check + tests
pnpm test:e2e # E2E tests (if applicable)✅ Use validatedActionWithUser or validatedActionWithAdminPermission for server actions ✅ Check tool types with branded type tags before execution ✅ Use Zod superRefine for cross-field validation ✅ Add unit tests (happy path + one failure mode) ✅ Run pnpm check before PR submission ✅ Include visual documentation for UI changes ✅ Use Conventional Commit format for PR titles ✅ Run E2E tests when touching critical flows
❌ Implement server actions without auth validators ❌ Assume tool type without runtime check ❌ Parse FormData manually (use validators) ❌ Mutate Zustand state directly (use shallow updates) ❌ Skip first-user tests on clean database ❌ Commit without running pnpm check ❌ Submit PR without visual docs (if UI change) ❌ Use non-conventional commit format
This skill prevents 8 documented issues:
Error: Unauthorized users accessing protected actions Why It Happens: Manual auth implementation is inconsistent Prevention: Use validatedActionWithUser or validatedActionWithAdminPermission
// ❌ 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
}
)Error: Runtime type errors when executing tools Why It Happens: Not checking tool type before execution Prevention: Use branded type tags for runtime narrowing
// ❌ 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)
}Error: Inconsistent error handling for form submissions Why It Happens: Manual FormData parsing with ad-hoc validation Prevention: Validators handle parsing automatically
// ❌ 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
})Error: Password mismatch validation not working Why It Happens: Separate validation for related fields Prevention: Use Zod 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"
})
}
})Error: Zustand state updates not triggering re-renders Why It Happens: Deep mutation of nested workflow state Prevention: Use shallow updates
// ❌ 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
)
}
}))Error: E2E tests failing on clean database Why It Happens: Running standard tests before first-user setup Prevention: Use correct test commands
# ❌ BAD: Running standard tests on clean DB
pnpm test:e2e:standard
# ✅ GOOD: Full suite with first-user setup
pnpm test:e2eError: Missing required environment variables causing crashes Why It Happens: Not copying .env.example to .env Prevention: Auto-generated .env on pnpm i
# Auto-generates .env on install
pnpm i
# Verify all required vars present
# Required: DATABASE_URL, at least one LLM_API_KEYError: CI/CD failures due to non-conventional commit format Why It Happens: Not following Conventional Commits standard Prevention: Use prefix + colon format
# ❌ 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 }
}
)When to use: Any server action that requires authentication
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)
}
}When to use: Handling multiple tool types in unified interface
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
)
}
}))When to use: Updating nested Zustand state without mutation
references/AGENTS.md - Full repository guidelines (loaded when detailed structure questions arise)references/CONTRIBUTING.md - Complete contribution process (loaded when PR standards questions arise)When Claude should load these: When user asks about detailed better-chatbot conventions, asks "what are the full guidelines?", or needs comprehensive contribution workflow details.
Required:
Testing:
This skill is based on better-chatbot production standards:
When contributing to better-chatbot:
pnpm i (auto-generates .env)pnpm dev and verify it startspnpm check before PRpnpm test:e2e if touching critical flowsQuestions? Issues?
references/AGENTS.md for detailed guidelinesreferences/CONTRIBUTING.md for PR processToken Efficiency: ~60% savings | Errors Prevented: 8 | Production Verified: Yes
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.