Document non-obvious details from Steps 1-5 implementation: - adiuvAI: deep-link protocol workaround, requestSingleInstanceLock, backup-key.ts device-bound key, loginWithOAuth fetch() vs get() - api: _pending_states in-memory limitation, nullable password_hash, OAUTH_REDIRECT_URI pointing to API not website, 409 unverified-email guard, OAuth testing patterns with AsyncMock + monkeypatch Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
13 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Keeping This File Up to Date
Update this file whenever a lesson is learned during development. Specifically, update CLAUDE.md when:
- A non-obvious architectural decision is made or discovered
- A gotcha, footgun, or surprising behavior is encountered (and the fix/workaround)
- A new command, workflow, or tool is added to the project
- A convention is established that isn't obvious from reading the code
- An integration detail is clarified (e.g., how the IPC protocol actually behaves, edge cases in the agent tool call cycle)
Do not add things already derivable from reading the code, generic best practices, or ephemeral task notes — only durable, reusable knowledge.
Repository Layout
This is a monorepo with git submodules. Each submodule is an independent repo with its own .claude/CLAUDE.md for detailed guidance.
| Directory | What | Submodule |
|---|---|---|
adiuvAI/ |
Electron desktop app (TypeScript/React) | git.muticolturano.com/adiuvAI/adiuvAI |
api/ |
FastAPI backend (Python) | git.muticolturano.com/adiuvAI/api |
website/ |
Landing page (single index.html) |
git.muticolturano.com/adiuvAI/website |
docs/ |
Planning docs & working memory (not a submodule) | -- |
After cloning, run git submodule update --init --recursive to populate submodule contents.
adiuvAI (Electron App)
Detailed docs:
adiuvAI/.claude/CLAUDE.mdcovers commands, architecture, AI subsystem, design context, and conventions in depth.
Commands
cd adiuvAI
npm run start # Start dev server (Electron + Vite)
npm run lint # ESLint
npm run knip # Dead code analysis
npm run make # Build installers (Windows/Linux/macOS)
npm run package # Package without creating installers
npx drizzle-kit generate # Generate migration from schema changes
npx drizzle-kit push # Push schema directly (dev only)
No test suite currently.
Architecture
Renderer (React 19 + TanStack Router)
↓ custom ipcLink (NOT electron-trpc — incompatible with tRPC v11)
Preload (contextBridge: window.electronTRPC + window.electronAI)
↓ IPC channels
Main Process (Node.js)
├── tRPC router (all CRUD + AI procedures)
├── SQLite (better-sqlite3 + Drizzle ORM, WAL mode)
├── LanceDB (vector embeddings, 1536-dim text-embedding-3-small)
└── LangGraph orchestrator (3 specialist agents, pluggable LLM providers)
This is a local-first app. All user data (tasks, notes, projects) lives in local SQLite. The AI system (LangGraph + LangChain) runs entirely in the Electron main process with pluggable providers (OpenAI, Anthropic, GitHub Copilot).
IPC channels:
'trpc'— bidirectional tRPC request/response (all CRUD)'ai:stream'— one-way token streaming from main → renderer'ai:action'— AI side-effects (e.g., task auto-created by agent)
Key source paths:
src/main/ipc.ts— Custom tRPC↔IPC bridgesrc/main/router/index.ts— All tRPC routers (~600 LOC)src/main/ai/orchestrator.ts— LangGraph intent routing + 3 agents (~991 LOC)src/main/db/schema.ts— 6 tables (clients, projects, tasks, checkpoints, notes, taskComments)src/renderer/routes/— File-based routing (TanStack Router auto-generatesrouteTree.gen.ts)src/renderer/components/ui/— shadcn/ui primitives (new-york theme, neutral colors)src/main/auth/auth-manager.ts— Login, register, logout, OAuth flow (singleton)src/main/auth/backup-key.ts— Device-specific AES-256 backup key (safeStorage, not password-derived)src/main/ai/token.ts— Two-tier token storage: safeStorage + electron-store fallback
Non-obvious details:
electron-trpcis NOT used — custom IPC bridge inipc.ts+ipcLink.tsbecause electron-trpc bundles tRPC v10 internals- Vite configs use
.mtsextension to avoid ESM/CJS conflicts with electron-forge forge.config.tshas complex cross-compilation hooks (downloads platform-specific native binaries for better-sqlite3 and LanceDB)- DB has no foreign key constraints — cascade deletes are implemented in tRPC procedures
- Timestamps are milliseconds (JavaScript
Date.getTime()), not ISO strings - Notes auto-embed to LanceDB on create/update (fire-and-forget, errors swallowed)
Google OAuth (adiuvAI side):
adiuvai://is NOT accepted by Google as a redirect URI — Google only acceptshttp://localhostorhttps://. The API backend exposesGET /auth/oauth/google/web-callbackwhich receives the Google redirect and immediately bounces toadiuvai://oauth/callback?.... The redirect URI registered in Google Cloud Console points to the backend, not the Electron app.app.requestSingleInstanceLock()is required for thesecond-instanceevent to fire on Windows/Linux. If it returnsfalse, callapp.quit()immediately (another instance is already running).- In dev (
process.defaultApp === true),setAsDefaultProtocolClient('adiuvai')must include[path.resolve(process.argv[1])]as the third argument so the OS protocol registration includes the entry script. loginWithOAuthusesfetch()directly (notthis.get()) because the authorize endpoint is public —get()throws when not authenticated.- The backup key in
backup-key.tsis stored inencryptedTokensunder the keybackup_key, reusinggetToken/setTokenfromtoken.ts. It is device-bound and never password-derived, so social-login users can use backup features without issue.
api (FastAPI Backend)
Commands
cd api
# Development
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
# Production
gunicorn app.main:app -k uvicorn.workers.UvicornWorker -w 4 --timeout 120
# Database migrations
alembic upgrade head
# Testing
pytest # all tests
pytest -v # verbose
pytest tests/test_agents.py # single file
pytest tests/test_agents.py -k test_name # single test
# Linting/formatting
ruff check .
ruff format .
# Docker (full stack: app + postgres + minio + qdrant)
docker compose up --build
Architecture
FastAPI app (app/main.py)
├── Middleware: TierRateLimiter → Sanitizer → CORS
├── HTTP Routes (app/api/routes/)
│ ├── auth.py — register, login, token refresh (bcrypt + HS256 JWT)
│ ├── chat.py — POST /chat, WS /chat/stream
│ ├── plans.py — execution plan playbooks
│ ├── storage.py — E2E-encrypted cloud storage (S3)
│ ├── backup.py — encrypted backup upload/download
│ ├── vectors.py — encrypted vector upsert/search (Pinecone/Qdrant)
│ ├── plugins.py — plugin marketplace (Power+ tier)
│ └── billing.py — Stripe subscriptions
├── Agent System (app/agents/)
│ ├── task_agent.py — 8 tools
│ ├── project_agent.py — 6 tools
│ ├── checkpoint_agent.py — 4 tools
│ └── note_agent.py — 5 tools
├── Orchestration (app/core/)
│ ├── orchestrator.py — intent classification + agent routing
│ ├── agent_registry.py — decorator-based agent registry
│ ├── execution_plan.py — server-side prompt templates + plan builder
│ ├── llm.py — LiteLLM factory (100+ providers)
│ └── memory_middleware.py
├── Billing (app/billing/)
│ ├── tier_manager.py — feature matrix (Free/Pro/Power/Team)
│ └── stripe_service.py — Stripe checkout + webhooks
├── Storage (app/storage/) — S3 blob store, vector store, encryption
└── Marketplace (app/marketplace/) — plugin catalog, review, revenue sharing
LLM routing: gpt-4o-mini classifies intent → routes to domain agent → agent uses gpt-4o with tools → tool calls describe client-side operations (JSON) → Electron executes locally and returns results.
Zero-trust data model: The backend never stores or decrypts user content. PostgreSQL holds only auth, billing, plugin metadata, and storage record pointers. All user data is E2E-encrypted before leaving the Electron client.
Key config: app/config/settings.py — all env vars via Pydantic Settings. Copy .env.example to .env for local dev. Stripe and S3 gracefully stub when keys aren't configured.
Database: PostgreSQL with async SQLAlchemy 2.0 + asyncpg. 9 ORM models in app/models.py. Alembic migrations in alembic/versions/.
Testing: pytest + pytest-asyncio. Fixtures in tests/conftest.py create in-memory SQLite + moto-mocked S3. Test users seeded per tier (free/pro/power/team).
Non-obvious details
- Tier from DB, not JWT:
get_current_userdecodes JWT but fetches authoritative tier fromsubscriptionstable — tier changes take effect immediately without re-login - Refresh tokens hashed: Plaintext returned to client, stored as SHA-256 in DB — server can never retrieve the plaintext (intentional)
- WebSocket auth via query param:
?token=<jwt>instead of Bearer header (WebSocket handshake limitation) - Prompt IP protection:
PromptTemplateRegistrykeeps prompts server-side; clients receive opaquetemplate_id.SanitizerMiddlewarestrips leaked fragments from responses - Agents don't execute operations: Tools return JSON describing client-side ops — the Electron client executes against local SQLite
- Alembic async/sync split: App uses
postgresql+asyncpg, Alembic CLI needspostgresql+psycopg2—env.pyhandles the URL conversion - Tool loop cap: Agent
_tool_loopstops after 5 iterations to prevent infinite loops - Route order matters:
/backup/historymust be declared before/backup/{backup_id}to avoid path param shadowing - CORS includes
app://: Electron uses customapp://protocol, not http/https - Vector search on encrypted data is not semantic: Backend derives deterministic 32-dim floats from blob SHA-256 for storage/search — a known trade-off
Google OAuth (api side):
- OAuth routes live in
app/api/routes/auth.py:GET /auth/oauth/{provider}/authorize,POST /auth/oauth/{provider}/callback,GET /auth/oauth/{provider}/web-callback(bounces to deep link, excluded from OpenAPI schema). - Provider abstraction in
app/auth/oauth_providers.py—GoogleOAuthProvideruseshttpxdirectly (noauthlib). PKCE S256 is implemented manually viagenerate_pkce_pair(). _pending_statesdict inroutes/auth.pyis in-memory — works for single-process dev but does not survive restarts and does not scale to multiple workers. Replace with Redis in production.users.password_hashis nullable — social-only users havepassword_hash=None.await db.flush()is required before creating a linkedOAuthAccountto populatenew_user.idbefore commit.OAUTH_REDIRECT_URImust point to the API backend (e.g.https://api.adiuvai.com/...), not the website domain.adiuvai.comis a static site with no server-side routing.- Unverified email + existing account = 409: if
email_verified=Falseand the email is already registered, the callback returns 409. Without this guard, branch 3 would attempt to INSERT a duplicate email and crash with a DB constraint violation (500). - Testing OAuth routes: mock
GoogleOAuthProvider.exchange_codeandget_userinfowithpatch.object(..., new=AsyncMock(...))— works because FastAPI instantiates a new provider per request. Usemonkeypatch.setattr(settings, "GOOGLE_AUTH_CLIENT_ID", ...)to simulate configured credentials without restarting the app.
Tier System
| Feature | Free | Pro | Power | Team |
|---|---|---|---|---|
| Rate limit | 20/min | 60/min | 120/min | 200/min |
| Agents | 3 | unlimited | unlimited | unlimited |
| Cloud storage | 0 GB | 5 GB | 25 GB | unlimited |
| Plugin marketplace | no | no | yes | yes |
Enforced in app/api/middleware/rate_limit.py (sliding window) and app/billing/tier_manager.py (feature checks + quota enforcement).
Cross-Project Integration
The Electron app and FastAPI backend communicate via WebSocket (/chat/stream):
- Electron connects with
?token=<jwt>query param - Client sends
ChatRequestJSON frame - Server streams text chunks, then a final frame:
{"done": true, "response": "...", "actions": []} - Server sends
tool_callframes → Electron executes against local SQLite → returnstool_result - Server pings every 30 seconds to keep connection alive
The Electron app also has a fully local AI path (LangGraph orchestrator in main process) that doesn't require the backend — this is the primary path for desktop use.
MCP Servers
- Langfuse Docs (
https://langfuse.com/api/mcp) — configured at workspace level for prompt management documentation - shadcn (
npx shadcn@latest mcp) — configured inadiuvAI/for UI component generation