These instructions are for AI assistants working in this project.
Always open @/openspec/AGENTS.md when the request:
- Mentions planning or proposals (words like proposal, spec, change, plan)
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
- Sounds ambiguous and you need the authoritative spec before coding
Use @/openspec/AGENTS.md to learn:
- How to create and apply change proposals
- Spec format and conventions
- Project structure and guidelines
Keep this managed block so 'openspec update' can refresh the instructions.
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
21st Agents - A local-first Electron desktop app for AI-powered code assistance. Users create chat sessions linked to local project folders, interact with Claude in Plan or Agent mode, and see real-time tool execution (bash, file edits, web search, etc.).
# Development
bun run dev # Start Electron with hot reload
# Build
bun run build # Compile app
bun run package # Package for current platform (dir)
bun run package:mac # Build macOS (DMG + ZIP)
bun run package:win # Build Windows (NSIS + portable)
bun run package:linux # Build Linux (AppImage + DEB)
# Database (Drizzle + SQLite)
bun run db:generate # Generate migrations from schema
bun run db:push # Push schema directly (dev only)src/
├── main/ # Electron main process
│ ├── index.ts # App entry, window lifecycle
│ ├── auth-manager.ts # OAuth flow, token refresh
│ ├── auth-store.ts # Encrypted credential storage (safeStorage)
│ ├── windows/main.ts # Window creation, IPC handlers
│ └── lib/
│ ├── db/ # Drizzle + SQLite
│ │ ├── index.ts # DB init, auto-migrate on startup
│ │ ├── schema/ # Drizzle table definitions
│ │ └── utils.ts # ID generation
│ └── trpc/routers/ # tRPC routers (projects, chats, claude)
│
├── preload/ # IPC bridge (context isolation)
│ └── index.ts # Exposes desktopApi + tRPC bridge
│
└── renderer/ # React 19 UI
├── App.tsx # Root with providers
├── features/
│ ├── agents/ # Main chat interface
│ │ ├── main/ # active-chat.tsx, new-chat-form.tsx
│ │ ├── ui/ # Tool renderers, preview, diff view
│ │ ├── commands/ # Slash commands (/plan, /agent, /clear)
│ │ ├── atoms/ # Jotai atoms for agent state
│ │ └── stores/ # Zustand store for sub-chats
│ ├── sidebar/ # Chat list, archive, navigation
│ ├── sub-chats/ # Tab/sidebar sub-chat management
│ └── layout/ # Main layout with resizable panels
├── components/ui/ # Radix UI wrappers (button, dialog, etc.)
└── lib/
├── atoms/ # Global Jotai atoms
├── stores/ # Global Zustand stores
├── trpc.ts # Real tRPC client
└── mock-api.ts # DEPRECATED - being replaced with real tRPC
Location: {userData}/data/agents.db (SQLite)
Schema: src/main/lib/db/schema/index.ts
// Three main tables:
projects → id, name, path (local folder), timestamps
chats → id, name, projectId, worktree fields, timestamps
sub_chats → id, name, chatId, sessionId, mode, messages (JSON)Auto-migration: On app start, initDatabase() runs migrations from drizzle/ folder (dev) or resources/migrations (packaged).
Queries:
import { getDatabase, projects, chats } from "../lib/db"
import { eq } from "drizzle-orm"
const db = getDatabase()
const allProjects = db.select().from(projects).all()
const projectChats = db.select().from(chats).where(eq(chats.projectId, id)).all()- Uses tRPC with
trpc-electronfor type-safe main↔renderer communication - All backend calls go through tRPC routers, not raw IPC
- Preload exposes
window.desktopApifor native features (window controls, clipboard, notifications)
- Jotai: UI state (selected chat, sidebar open, preview settings)
- Zustand: Sub-chat tabs and pinned state (persisted to localStorage)
- React Query: Server state via tRPC (auto-caching, refetch)
- Dynamic import of
@anthropic-ai/claude-codeSDK - Two modes: "plan" (read-only) and "agent" (full permissions)
- Session resume via
sessionIdstored in SubChat - Message streaming via tRPC subscription (
claude.onMessage)
| Layer | Tech |
|---|---|
| Desktop | Electron 33.4.5, electron-vite, electron-builder |
| UI | React 19, TypeScript 5.4.5, Tailwind CSS |
| Components | Radix UI, Lucide icons, Motion, Sonner |
| State | Jotai, Zustand, React Query |
| Backend | tRPC, Drizzle ORM, better-sqlite3 |
| AI | @anthropic-ai/claude-code |
| Package Manager | bun |
- Components: PascalCase (
ActiveChat.tsx,AgentsSidebar.tsx) - Utilities/hooks: camelCase (
useFileUpload.ts,formatters.ts) - Stores: kebab-case (
sub-chat-store.ts,agent-chat-store.ts) - Atoms: camelCase with
Atomsuffix (selectedAgentChatIdAtom)
electron.vite.config.ts- Build config (main/preload/renderer entries)src/main/lib/db/schema/index.ts- Drizzle schema (source of truth)src/main/lib/db/index.ts- DB initialization + auto-migratesrc/renderer/features/agents/atoms/index.ts- Agent UI state atomssrc/renderer/features/agents/main/active-chat.tsx- Main chat componentsrc/main/lib/trpc/routers/claude.ts- Claude SDK integration
When testing auth flows or behavior for new users, you need to simulate a fresh install:
# 1. Clear all app data (auth, database, settings)
rm -rf ~/Library/Application\ Support/Agents\ Dev/
# 2. Reset macOS protocol handler registration (if testing deep links)
/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister -kill -r -domain local -domain system -domain user
# 3. Clear app preferences
defaults delete dev.21st.agents.dev # Dev mode
defaults delete dev.21st.agents # Production
# 4. Run in dev mode with clean state
cd apps/desktop
bun run devCommon First-Install Bugs:
- OAuth deep link not working: macOS Launch Services may not immediately recognize protocol handlers on first app launch. User may need to click "Sign in" again after the first attempt.
- Folder dialog not appearing: Window focus timing issues on first launch. Fixed by ensuring window focus before showing
dialog.showOpenDialog().
Dev vs Production App:
- Dev mode uses
twentyfirst-agents-dev://protocol - Dev mode uses separate userData path (
~/Library/Application Support/Agents Dev/) - This prevents conflicts between dev and production installs
- Keychain profile:
21st-notarize - Create with:
xcrun notarytool store-credentials "21st-notarize" --apple-id YOUR_APPLE_ID --team-id YOUR_TEAM_ID
# Full release (build, sign, submit notarization, upload to CDN)
bun run release
# Or step by step:
bun run build # Compile TypeScript
bun run package:mac # Build & sign macOS app
bun run dist:manifest # Generate latest-mac.yml manifests
./scripts/upload-release-wrangler.sh # Submit notarization & upload to R2 CDNnpm version patch --no-git-tag-version # 0.0.27 → 0.0.28- Wait for notarization (2-5 min):
xcrun notarytool history --keychain-profile "21st-notarize" - Staple DMGs:
cd release && xcrun stapler staple *.dmg - Re-upload stapled DMGs to R2 and GitHub (see RELEASE.md for commands)
- Update changelog:
gh release edit v0.0.X --notes "..." - Upload manifests (triggers auto-updates!) — see RELEASE.md
- Sync to public:
./scripts/sync-to-public.sh
| File | Purpose |
|---|---|
latest-mac.yml |
Manifest for arm64 auto-updates |
latest-mac-x64.yml |
Manifest for Intel auto-updates |
1Code-{version}-arm64-mac.zip |
Auto-update payload (arm64) |
1Code-{version}-mac.zip |
Auto-update payload (Intel) |
1Code-{version}-arm64.dmg |
Manual download (arm64) |
1Code-{version}.dmg |
Manual download (Intel) |
- App checks
https://cdn.21st.dev/releases/desktop/latest-mac.ymlon startup and when window regains focus (with 1 min cooldown) - If version in manifest > current version, shows "Update Available" banner
- User clicks Download → downloads ZIP in background
- User clicks "Restart Now" → installs update and restarts
Done:
- Drizzle ORM setup with schema (projects, chats, sub_chats)
- Auto-migration on app startup
- tRPC routers structure
In Progress:
- Replacing
mock-api.tswith real tRPC calls in renderer - ProjectSelector component (local folder picker)
Planned:
- Git worktree per chat (isolation)
- Claude Code execution in worktree path
- Full feature parity with web app
When debugging runtime issues in the renderer or main process, use the structured debug logging system. This avoids asking the user to manually copy-paste console output.
Start the server:
bun packages/debug/src/server.ts &Instrument renderer code (no import needed, fails silently):
fetch('http://localhost:7799/log',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tag:'TAG',msg:'MESSAGE',data:{},ts:Date.now()})}).catch(()=>{});Read logs: Read .debug/logs.ndjson - each line is a JSON object with tag, msg, data, ts.
Clear logs: curl -X DELETE http://localhost:7799/logs
Workflow: Hypothesize → instrument → user reproduces → read logs → fix with evidence → verify → remove instrumentation.
See packages/debug/INSTRUCTIONS.md for the full protocol.