Projects
The project entity that scopes a user's work inside their account or inside their active organization - ownership, the API, config.projects.enabled, and the useActiveProject hook.
A project is a named container a user switches between inside one account, gated by config.projects.enabled. It holds no members, no roles, and no money of its own: ownership follows tenancy, and who pays never changes.
| Row | projects table: id, name, logo, the two nullable owner columns (userId, organizationId), createdAt, updatedAt |
| Owner | the active organization when tenancy.multiTenant is on, otherwise the signed-in user |
| Seeded | a Default per owner, from whichever seam the tenancy mode uses: organization creation when multiTenant is on, account creation when it is off - never both |
| Membership | none in v1 - every member of an organization sees and edits all of its projects |
| Logo | optional image, set on /settings/project (and on the desktop app's Project screen, by URL) and drawn by the sidebar switcher; falls back to the organization's logo, then to initials |
| Limits | MAX_PROJECTS_PER_OWNER 100, MAX_PROJECT_NAME_LENGTH 100, MAX_PROJECT_LOGO_LENGTH 2048 (all from @repo/config) |
Little else is project-scoped for you: an AI integration connection can be placed in a project, and every other entity keeps the ownership it had. Metering is untouched either way - a project is a scoping seam you build on, never a billing boundary.
Enable it
projects manifest option, or set projects: { enabled: true } in packages/config/src/index.ts - the same file the CLI writes./settings/project page and its settings tab all mount behind that one flag. With tenancy.multiTenant on too, one sidebar row leads with the active project and puts the organization list a tap away.The table, the API routes and the seeds ship either way, so turning it on later finds a Default already seeded for every owner that has passed its seam.
The flag is not the only thing that scopes projects. Enabling tenancy.multiTenant alone already scopes the desktop app's on-device data by an invisible per-organization project, so projects adds the UI rather than the scoping. In a single-tenant app, enabling projects moves desktop automations to the reachable-allowlist mode, where an explicit sign-out (or an expired session) stops every unattended automation on that device.
API
Mounted under the API base at /projects, always - the flag gates UI, never routes. Every handler requires a session, and every one but /reachable scopes to the caller's resolved owner.
| Route | Contract |
|---|---|
GET /projects | 200 { projects }, oldest first by createdAt. Seeds the owner's Default on first read - best effort, and skipped entirely when multi-tenant with no active organization, which answers 200 { projects: [] } |
POST /projects | { name } trimmed, 1-100 chars -> 201 { project } |
PUT /projects/:id | { name, logo? } -> 200 { project }; an omitted logo is left alone, an empty one clears it; a row of another owner is 404 |
DELETE /projects/:id | 200 {}; a row of another owner is 404, unless the caller is at the one-project floor, which answers LAST_PROJECT for any id |
GET /projects/reachable | 200 { ids } - the ids a correctly configured client can surface and stop, across every organization the caller belongs to rather than the active one |
| Error code | HTTP | Raised when |
|---|---|---|
LAST_PROJECT | 400 | The delete would leave the owner with none |
PROJECT_LIMIT | 400 | The create would pass 100 projects for this owner |
NO_ACTIVE_ORGANIZATION | 400 | A mutation while multi-tenant with no active organization. GET /projects never raises it - it answers 200 { projects: [] } for the same session |
Translate the codes client-side; @repo/config exports each one so the API, the web app and the desktop app agree on the string.
Reading the active project
useActiveProject() (apps/web/hooks/use-active-project.tsx) is the plumbing your own features scope by. Its provider sits above the dashboard sidebar; the pointer persists per (user, organization) in localStorage and refetches when the active organization changes.
const { projects, activeProject, activeProjectId, setActiveProject, refresh, isPending } =
useActiveProject();With the flag off it returns the same shape, empty and settled - projects: [], activeProjectId: null, isPending: false - so a component can read it unconditionally.