This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This is a monorepo with the following structure:
apps/web-foldkit/- Web frontend built with Foldkit (Effect-based Elm architecture), Vite and TailwindCSS. Also the desktop app's webviewapps/backend/- The api Cloudflare Worker (Effect-TS): HTTP API, RPC, and the Durable Objects that hold the data (src/worker/objects/:OrgObject,ChannelObject,UserObject) over the directory D1 databaseapps/bot-gateway/- Bot gateway Worker and itsBotGatewayDurable Object (one per bot)packages/db/- Shared database package: effect-orm SQLite table definitions, the per-store table lists (stores.ts), theDatabaseservice, and the generated migrationspackages/sync-do/- Durable Object sync runtime: change triggers,_changeslog, broadcaster, sockets, outbox and inboxpackages/domain/- Shared domain types, RPC and HTTP contracts, and the sync protocol (src/sync/)packages/infra/- Alchemy stack helpers (stages, Worker props, env, the directory D1); the stack itself is the rootalchemy.run.ts.context/- Git Subtrees for context of how to use specific libraries (In this case Effect and TanStack DB)
Source code for dependencies is available in opensrc/ for deeper understanding of implementation details.
See opensrc/sources.json for the list of available packages and their versions.
Use this source code when you need to understand how a package works internally, not just its types/interface.
To fetch source code for a package or repository you need to understand, run:
npx opensrc <package> # npm package (e.g., npx opensrc zod)
npx opensrc pypi:<package> # Python package (e.g., npx opensrc pypi:requests)
npx opensrc crates:<package> # Rust crate (e.g., npx opensrc crates:serde)
npx opensrc <owner>/<repo> # GitHub repo (e.g., npx opensrc vercel/ai)IMPORTANT: Always check the .context/ directory for library-specific documentation and example code before implementing features with these libraries.
Available library contexts:
.context/effect/- Effect-TS functional programming patterns and examples.context/tanstack-db/- TanStack-DB
When working with Effect or TanStack DB, refer to these directories for best practices, API usage, and implementation patterns.
CRITICAL: NEVER start the dev server - it should already be running! Do not run bun run dev, bun run alchemy:dev, PORT=3000 bun run dev, or any variant of starting the dev server.
Local dev is bun run alchemy:dev (alias bun run dev): it runs every Worker in workerd, the Durable Objects and the directory D1 included (state in .alchemy/), plus the web app's Vite server. It reads only .env.local (see .env.local.example, or bun run setup); docker compose up -d starts MinIO for uploads. There is no Postgres, Electric or cluster.
bun run dev/bun run alchemy:dev- Start the whole stack locally (DO NOT USE - already running)bun run alchemy:typecheck- Typecheck the alchemy stack (alchemy.run.ts,apps/*/alchemy.run.ts)bun run setup- Interactive setup: writes.env.localbun run build- Build all apps and run typecheckbun run typecheck- Run TypeScript typechecking across all packagesbun run format- Format code using Oxc (includes linting and auto-fixes)bun run test- Run tests in watch mode using Vitestbun run test:once- Run all tests oncebun run test:coverage- Run tests with coverage report
bun run dev- Start Vite dev server on port 3100 (DO NOT USE - already running)bun run build- Build for productionbun run typecheck- TypeScript checking without emitting filesbun run test- Run the app's Vitest suite
bun run typecheck- TypeScript checking (shared code, and the Worker graph undertsconfig.worker.json)bun run test- Run the backend's Vitest suite (objects and repositories run on in-memory node SQLite)bun run test:sync/bun run test:policies- Chat-sync, or policy, tests only
bun run db:generate-sqlite --name <name> [--hints <file.json>]- Diff each store's tables against its snapshot (snapshots/<store>.json) and append the next migration (src/migrations.generated.ts, plusmigrations/directory/<NNNN>_<name>/migration.sqlfor the directory store); writes nothing when no schema changed
- Framework: Foldkit (Model / Message / update / view on Effect) with TypeScript
- Build Tool: Vite
- Routing: Flat route union in
src/route.ts - Styling: TailwindCSS v4,
tailwind-variantsstyles insrc/core/components/ui/*.styles.ts - UI Components:
src/ui/on@foldkit/ui - Data: TanStack DB collections synced from the Durable Objects over WebSockets (
src/core/db/collections.ts,libs/effect-db-collection) - Rich Text: ProseMirror composer (
src/editor/) - Auth: Hazel auth (
@yielded/auth)
- Runtime: Cloudflare Workers (the api Worker), deployed and run locally with Alchemy
- Framework: Effect-TS for functional programming
- Data: SQLite everywhere through effect-orm (
@maple-dev/effect-orm): each Durable Object's storage, and D1 for the global directory - Auth: Hazel auth (
@hazel/auth, on@yielded/auth): GitHub/Google sign-in, passkeys, cookie sessions; Clerk is only used to verify Maple's OAuth tokens (services/oauth-bearer-auth.ts) - API: RPC-style endpoints via Effect Http Api
- Package Manager: Bun with workspaces
- Monorepo: Turborepo for task orchestration
- Linting/Formatting: OXC (replaces ESLint + Prettier)
- Testing: Vitest
- TypeScript: Strict mode enabled across all packages
The project uses OXC for consistent formatting:
- Tab indentation (4 spaces)
- Double quotes for strings
- Trailing commas
- 110 character line width
- Import organization and sorting enabled
Run bun run format:fix to apply formatting and fix linting issues automatically.
@hazel/schema - Branded ID types (foundational, minimal dependencies):
import type { OrganizationId, ChannelId, UserId, MessageId } from "@hazel/schema"@hazel/domain - RPC and HTTP contracts, the sync protocol, and models:
import { Rpc, Http, Sync } from "@hazel/domain"
import { Message, Channel, User } from "@hazel/domain/models"ALWAYS use the branded ID types from @hazel/schema instead of plain strings or as any casts. This ensures type safety across the codebase.
// ✅ CORRECT - Use branded types from @hazel/schema
import type { OrganizationId, ChannelId, UserId } from "@hazel/schema"
function getChannel(channelId: ChannelId) { ... }
function listDomains(organizationId: OrganizationId) { ... }// ❌ WRONG - Don't use plain strings or `as any`
function getChannel(channelId: string) { ... }
payload: { id: organizationId as any } // Never do this!Available branded types in @hazel/schema: OrganizationId, ChannelId, UserId, MessageId, BotId, InvitationId, ChannelMemberId, OrganizationMemberId, TransactionId, and many more. See packages/schema/src/ids.ts for the full list.
There is no central database. Data lives in SQLite stores (infra/durable-object-sync-plan.md §1), each with the table list in packages/db/src/stores.ts:
- directory (D1
DIRECTORY_DB, global): users,auth_*, bots, bot commands, invitations, and the routing indexes (org_index,membership_index,channel_index, ...) - org (
OrgObject, one per organization): organizations, members, channels, channel members, sections,channel_access, integrations, webhooks, Connect; plus read-only projections (users, presence, bots) - channel (
ChannelObject, one per channel, threads too): messages, reactions, pins, attachments, typing; projectionschannel_viewers,channel_bots - user (
UserObject, one per user): notifications, presence,user_organizations
Uses effect-orm (@maple-dev/effect-orm, source in ~/Documents/GitHub/effect-clickhouse). Tables are SQLite.table definitions in packages/db/src/tables/.
import { Database, SQLite, schema } from "@hazel/db"
const db = yield * Database.Database
const channel =
yield *
db.first(
SQLite.from(schema.channelsTable)
.select()
.where(($) => [$.id.eq(channelId), $.deletedAt.isNull()]),
)- Inside an object,
Databaseis the object's own SQLite; on the api Worker it is D1. A handler touches only its own store and sends everything else through the outbox (apps/backend/src/worker/objects/effects/); a projection is written only by its owner's outbox deliveries. db.transaction(effect)works on Durable Object SQLite; D1 has no transactions (it refuses), so directory writes are single statements or batches, and idempotent.- Schema changes (every store is live with production data, so migrations are append-only; never edit a committed one,
0001_initialincluded):- Edit
src/tables(andsrc/tables/extra-indexes.tsfor partial/expression indexes,src/stores.tsfor a new table). cd packages/db && bun run db:generate-sqlite --name <snake_case_name>. For each store whose tables differ fromsnapshots/<store>.jsonit appends<NNNN>_<name>(effect-orm's SQLite diff:ADD COLUMN,CREATE TABLE/INDEX, or a table rebuild) tosrc/migrations.generated.ts, rewrites the snapshot, and for the directory store writesmigrations/directory/<NNNN>_<name>/migration.sql. A drop or rename stops with the hints it needs (confirm_data_loss/confirm_rename, see effect-orm'sdocs/migrations.md#sqlite); put them in a JSON array and pass--hints <file>.- Commit the generated files and snapshots together.
schema-ddl.test.tsfails while a store's tables differ from its snapshot.
- Edit
- Applying: object stores (org, channel, user) migrate lazily: each Durable Object instance runs its pending
storeMigrationswhen it activates (makeObjectRuntime→applyMigrations, ledger in_migrations), so an object idle since the deploy keeps the old schema until its next request. Code must not assume every object has migrated, and a new column must be nullable or have a default. Alchemy applies the D1 migrations inmigrations/directory/on deploy and underalchemy dev.
- Backend follows Effect-TS patterns for error handling and dependency injection
- Real-time sync: each object streams its tables to the client over a WebSocket (
GET /sync/:kind/:id,apps/backend/src/worker/sync-route.ts); change triggers feed a_changeslog, and RPC results carry atransactionIdthe client awaits (packages/sync-do) - RPCs: the api Worker authenticates and routes each RPC to its object (
org,channel,user) or runs it against D1 (apps/backend/src/rpc/routing.ts); the handlers inapps/backend/src/rpc/handlersrun inside the objects, and authorization runs there too - Background work runs in the objects: outbox deliveries, alarms (RSS polling, typing expiry, upload cleanup, presence), thread naming
- Authentication handled by Hazel auth (
packages/auth); the RPC and HttpApi middlewares resolve the session cookie, a session bearer credential (native clients) or a bot token toCurrentUser - Shared database package ensures type safety between frontend and backend
- Domain package (
packages/domain/) contains shared contracts:- RPC definitions for client-server communication
- HTTP API definitions
- The sync protocol frames (
src/sync/) - Shared error types and data models
A table the client syncs needs all of:
- The store: add the table to its store's list in
packages/db/src/stores.ts(and a projection's to each store it is copied into), then runbun run db:generate-sqlite --name <name>. - The sync source: add a
SyncTableSpec(the table, its per-principalvisiblecondition,dependsOn) to the object's source inapps/backend/src/worker/objects/sources/(org-source.ts,channel-source.ts,user-source.ts). A table that is not listed there is never streamed. - The collection: add it in
apps/web-foldkit/src/core/db/collections.ts(orgCollection,channelCollectionOf, or auserSourcescollection).
Skill Available: Run
/effect-best-practicesfor comprehensive Effect-TS patterns. The skill auto-activates when writing Context.Service, Schema.TaggedError, or Layer composition.
Version note: The repo is on Effect
4.0.6stable. Formereffect/unstable/*modules import fromeffect/*(e.g.effect/http,effect/rpc,effect/workflow), andeffect/unstable/httpapiis noweffect/http-api. Constructors are PascalCase (Config.String,Schema.TaggedError,Flag.Boolean).
Naming note: As of
effect@4.0.0-beta.57theServiceMapmodule was renamed back toContext. The v3Context.TagAPI is gone —Context.Service(with amakeoption) is the v4 way to declare services. Older code may still referenceServiceMap; treat any newServiceMapimport as a mistake.
ALWAYS prefer Context.Service (from effect) for defining services. Context.Service with a make option stores the constructor effect on the class. You must define the layer explicitly using Layer.effect.
// ✅ CORRECT - Use Context.Service with make and explicit layer
import { Context, Effect, Layer } from "effect"
export class MyService extends Context.Service<MyService>()("MyService", {
make: Effect.gen(function* () {
// ... implementation
return {/* methods */}
}),
}) {
static readonly layer = Layer.effect(this, this.make)
}
// Usage: MyService.layer, yield* MyService// ❌ WRONG - Don't use the legacy ServiceMap name
import { ServiceMap } from "effect" // <- no longer exported in beta.57+// ❌ WRONG - Don't use v3 Effect.Service pattern
export class MyService extends Effect.Service<MyService>()("MyService", {
accessors: true,
effect: Effect.gen(function* () {
/* ... */
}),
}) {}Wire service dependencies using Layer.provide on the layer. The v3 dependencies array no longer exists.
// ✅ CORRECT - Dependencies wired via Layer.provide on the layer
export class MyService extends Context.Service<MyService>()("MyService", {
make: Effect.gen(function* () {
const db = yield* DatabaseService
const cache = yield* CacheService
// ... implementation
return {/* methods */}
}),
}) {
static readonly layer = Layer.effect(this, this.make).pipe(
Layer.provide(DatabaseService.layer),
Layer.provide(CacheService.layer),
)
}
// Usage is simple - MyService.layer includes all dependencies
const MainLive = Layer.mergeAll(MyService.layer, OtherService.layer)// ❌ WRONG - v3 dependencies array no longer exists
export class MyService extends Effect.Service<MyService>()("MyService", {
dependencies: [DatabaseService.Default, CacheService.Default],
effect: Effect.gen(function* () {
/* ... */
}),
}) {}When it's acceptable to omit Layer.provide:
- Infrastructure layers that are globally provided (e.g., Redis, Database) may be intentionally "leaked" to be provided once at the application root
- When a dependency is explicitly meant to be provided by the consumer
ALWAYS prefer catchTag or catchTags over catchAll when handling errors. This preserves error type information and allows for proper error handling throughout the stack.
// ❌ WRONG - catchAll loses error type information
yield *
someEffect.pipe(
Effect.catchAll((err) => Effect.fail(new InternalServerError({ message: "Something failed" }))),
)
// ✅ CORRECT - catchTag preserves error types and provides specific handling
yield *
someEffect.pipe(
Effect.catchTag("RequestError", (err) =>
Effect.fail(new IntegrationUnavailableError({ message: "Service unreachable" })),
),
Effect.catchTag("ResponseError", (err) =>
Effect.fail(new InternalServerError({ message: err.reason, cause: String(err) })),
),
)Why this matters:
- Preserves error type information for downstream handlers
- Enables proper error handling on frontend (specific messages per error type)
- Makes debugging easier with clear error categorization
- Allows type-safe error handling with
Effect.catchTags
Use Brandfetch CDN for integration brand logos/icons. See apps/web-foldkit/src/core/lib/integrations/__data.ts for the helper function.
URL Pattern: https://cdn.brandfetch.io/{domain}/w/{size}/h/{size}/theme/{theme}/{type}
domain: The company's domain (e.g.,linear.app,github.com,figma.com)size: Image dimensions in pixels (e.g., 64, 512)theme:lightordarktype:icon(small inline logos) orsymbol(larger brand marks)
Example:
// For small inline icons, use type="icon"
<img src="https://cdn.brandfetch.io/linear.app/w/64/h/64/theme/dark/icon" alt="Linear" className="size-4" />Hazel Chat serves startup teams, developer teams, and enterprise teams who need focused, reliable team communication. Users are technical and design-conscious — they expect a fast, well-crafted tool that stays out of their way and lets them focus on work.
Calm, polished, reliable. The interface should feel like a precision instrument — confident and trustworthy without being cold. Every interaction should reinforce that this is a tool built with care.
Emotional goals: Confidence & clarity (users always know where they are), calm & focus (a quiet space for conversations), speed & efficiency (everything feels instant).
Reference: Linear / Raycast — clean, fast, keyboard-first with a refined developer-tools aesthetic. Crisp typography, restrained color, purposeful motion.
Anti-references: Discord (too noisy/gaming), Microsoft Teams (too corporate/cluttered), old-school Slack (too colorful/dated). Avoid visual noise, excessive color variety, and playful/whimsical elements.
Theme: Light and dark mode supported. Default brand purple (#6938EF) with customizable presets. Inter font family. OKLch-based color system with neutral gray defaults.
Key design files:
- Theme tokens:
apps/web-foldkit/src/core/styles/theme.css - Tailwind config & animations:
apps/web-foldkit/src/core/styles/styles.css - Theme presets:
apps/web-foldkit/src/core/lib/theme/presets.ts - Component library:
apps/web-foldkit/src/ui/
- Clarity over cleverness — Every element should have a clear purpose. Prefer obvious UI patterns over novel ones. Information hierarchy should be immediately readable.
- Quiet confidence — Use restraint with color, motion, and decoration. Let whitespace, typography, and alignment do the heavy lifting. The interface should feel calm, not sterile.
- Speed is a feature — Perceived and actual performance matter equally. Transitions should be fast (100-200ms). Keyboard shortcuts for power users. No unnecessary loading states or animations.
- Consistency compounds — Use the existing design token system (
theme.css,tailwind-variants). Reuse component patterns fromcomponents/ui/. Match spacing, radius, and color scales across features. - Accessible by default — Follow React Aria patterns. Respect
prefers-reduced-motion. Ensure keyboard navigability. WCAG AA contrast minimums. Touch targets at 44x44px minimum.