This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Kaneo is a self-hosted project management platform built with simplicity and performance as core principles. The codebase is organized as a pnpm monorepo with TurboRepo.
Key Philosophy: Features exist to solve real problems, not to impress. Avoid over-engineering - keep solutions simple and focused. Don't add features, refactoring, or improvements beyond what was asked.
# Install dependencies (uses pnpm)
pnpm install
# Start all development servers (API + web)
pnpm dev
# Lint and auto-fix code (Biome)
pnpm lint
# Build all packages
pnpm build# Run API in development mode
pnpm --filter @kaneo/api dev
# Build API
pnpm --filter @kaneo/api build
# Generate database migrations (after schema changes)
pnpm --filter @kaneo/api db:generate
# Run database migrations (auto-runs on API startup)
pnpm --filter @kaneo/api db:migrate
# Open Drizzle Studio (database GUI)
pnpm --filter @kaneo/api db:studio
# Lint API code
pnpm --filter @kaneo/api lint# Run web app in development mode
pnpm --filter @kaneo/web dev
# Build web app for production
pnpm --filter @kaneo/web build
# Preview production build
pnpm --filter @kaneo/web preview
# Lint web code
pnpm --filter @kaneo/web lintkaneo/
├── apps/
│ ├── api/ # Backend API (Hono/Node.js/PostgreSQL)
│ ├── web/ # Frontend app (React/Vite/TanStack)
│ └── docs/ # Documentation site (Next.js)
├── packages/
│ ├── email/ # Email utilities
│ ├── libs/ # Shared libraries
│ └── typescript-config/ # TypeScript configurations
└── charts/ # Kubernetes Helm charts
Backend (API)
- Framework: Hono (lightweight web framework)
- Database: PostgreSQL with Drizzle ORM
- Authentication: Better Auth
- Validation: Valibot (Zod is also present, used by Better Auth and some schemas)
- API Documentation: OpenAPI (hono-openapi)
- IDs: CUID2 (via @paralleldrive/cuid2)
Frontend (Web)
- Framework: React 19+
- Routing: TanStack Router (file-based)
- Data Fetching: TanStack Query (React Query)
- Build Tool: Vite
- Styling: Tailwind CSS v4
- State Management: Zustand
- UI Components: Radix UI primitives
Backend API Structure
- Routes organized by feature in
apps/api/src/{feature}/ - Controller pattern: business logic extracted to
{feature}/controllers/ - All routes use OpenAPI decorators (
describeRoute) - All inputs validated with Valibot schemas
- Migrations auto-run on API startup
Frontend Structure
- File-based routing in
apps/web/src/routes/ - Query hooks in
apps/web/src/hooks/queries/ - Mutation hooks in
apps/web/src/hooks/mutations/ - API fetchers in
apps/web/src/fetchers/{feature}/ - Components in
apps/web/src/components/
Database Schema Conventions
- All tables use CUID2 for primary keys (
createId()) - Every table has
createdAtandupdatedAttimestamps - Foreign keys always specify cascade behavior (
onDelete,onUpdate) - Indexes on frequently queried columns (especially foreign keys)
- Schema defined in
apps/api/src/database/schema.ts - Relations defined in
apps/api/src/database/relations.ts
Authentication Flow
- Better Auth handles authentication
- User context available in Hono via
c.get("userId"),c.get("user"),c.get("session") - API keys supported via Bearer token
- Frontend uses Better Auth client from
@/lib/auth-client
Event System
- Events published for activity tracking
- Use
publishEvent()fromapps/api/src/events/ - Events tracked for features like status changes, assignments, etc.
- Indentation: Spaces for JavaScript/TypeScript/TSX (tabs for other file types)
- Quotes: Double quotes
- Semicolons: Required
- Ignored files: CSS and
package.jsonfiles are excluded from Biome linting/formatting - Run
pnpm lintto auto-fix
- Prefer
typeoverinterface(only use interface when extending/merging) - Prefer type inference when obvious
- File naming: PascalCase for components, kebab-case for utilities/hooks
- Hooks use
useprefix:use-task.ts
- External packages
- Internal packages (
@/aliases) - Relative imports Biome auto-organizes imports.
Use Conventional Commits:
feat:- New featuresfix:- Bug fixesdocs:- Documentationrefactor:- Code refactoringchore:- Maintenance tasks
Husky enforces commit message format via commitlint.
The pre-commit hook (.husky/pre-commit) runs two checks:
biome ci .— linting and formatting validationpnpm run build— full monorepo build
Commits will be slow due to the build step. Ensure code compiles before committing.
Single .env file in project root shared by all apps.
Required variables:
KANEO_CLIENT_URL- Web app URL (e.g., http://localhost:5173)KANEO_API_URL- API URL (e.g., http://localhost:1337)AUTH_SECRET- JWT secret (min 32 chars)DATABASE_URL- PostgreSQL connection stringPOSTGRES_DB,POSTGRES_USER,POSTGRES_PASSWORD
Optional:
CORS_ORIGINS- Comma-separated allowed origins (empty = allow all in dev)VITE_API_URL- API URL for web dev (defaults to http://localhost:1337)REDIS_URL- Redis connection string for multi-instance WebSocket broadcasts via Pub/Sub (omit for single-instance in-memory mode)- SSO providers (GitHub, Google, Discord, Custom OAuth/OIDC)
- SMTP configuration
See ENVIRONMENT_SETUP.md for detailed configuration and troubleshooting.
- Read before modifying: Never propose changes to code you haven't read
- Use existing patterns: Follow the established controller/fetcher/hook patterns
- Avoid over-engineering: Don't add features beyond what's requested
- Type safety: Let TypeScript guide you - all APIs are fully typed
- Validate inputs: Always use Valibot schemas for API inputs
- Error handling: Backend uses HTTPException, frontend uses toast notifications
- Modify schema in
apps/api/src/database/schema.ts - Generate migration:
pnpm --filter @kaneo/api db:generate - Migration auto-runs on next API startup
- Always use CUID2 for IDs, include timestamps, specify cascade behavior
- Create controller in
apps/api/src/{feature}/controllers/ - Add route in
apps/api/src/{feature}/index.ts - Use
describeRoutefor OpenAPI docs - Use
validatorwith Valibot schema - Keep route handler thin - business logic in controller
- Create fetcher in
apps/web/src/fetchers/{feature}/ - Create query/mutation hook in
apps/web/src/hooks/ - Use TanStack Query for caching
- Handle loading/error states properly
- Use toast notifications (sonner) for user feedback
- Package Manager: This project uses pnpm (pinned to
10.32.1viapackageManagerfield), not npm or yarn. Requires Node>=18 - Migrations: Auto-run on API startup, stored in
apps/api/drizzle/ - Development Ports: API runs on 1337, web runs on 5173
- Hot Reload: Both API and web have watch mode via
pnpm dev - CORS: Configured in API index.ts, controlled by
CORS_ORIGINSenv var - Testing: Run
pnpm testat the repo root (Turbo runstestin packages that define it: API unit tests, web unit/component tests, shared packages). API integration tests:pnpm test:integration(requires PostgreSQL; env is set intests/api-integration/setup.ts; CI uses.github/workflows/ci.yml). Vitest configs:apps/api/vitest.config.ts(unit),apps/api/vitest.integration.config.ts(integration),apps/web/vitest.config.ts(web). Integration tests live undertests/api-integration/; API unit tests undertests/api/. - Security: Never commit secrets, always validate inputs, sanitize outputs
// apps/api/src/{feature}/index.ts
import { Hono } from "hono";
import { describeRoute, validator } from "hono-openapi";
import * as v from "valibot";
import getItem from "./controllers/get-item";
const feature = new Hono<{ Variables: { userId: string } }>()
.get("/:id",
describeRoute({
operationId: "getItem",
tags: ["Feature"],
description: "Get item by ID"
}),
validator("param", v.object({ id: v.string() })),
async (c) => {
const { id } = c.req.valid("param");
const item = await getItem(id);
return c.json(item);
}
);// apps/web/src/hooks/queries/{feature}/use-item.ts
import { useQuery } from "@tanstack/react-query";
import { getItem } from "@/fetchers/{feature}/get-item";
export function useItem(itemId: string) {
return useQuery({
queryKey: ["item", itemId],
queryFn: () => getItem(itemId),
});
}// apps/api/src/database/schema.ts
export const exampleTable = pgTable("example", {
id: text("id").$defaultFn(() => createId()).primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projectTable.id, {
onDelete: "cascade",
onUpdate: "cascade",
}),
title: text("title").notNull(),
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { mode: "date" })
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
}, (table) => [
index("example_projectId_idx").on(table.projectId),
]);