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), aiChangesWithoutApproval (see AI changes, false by default), 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. None at all when projects.seedDefault is false |
| Membership | none in v1 - every member of an organization sees and edits all of its projects |
| Logo | optional image, set on /settings/project (the same screen in the desktop app) 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 to true in packages/config/src/index.ts - the same file the CLI writes./settings/project page and its run of the settings rail 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.
Skip the Default project
Set projects: { enabled: true, seedDefault: false } when a project is something your users should create themselves. Signup, organization creation and GET /projects then seed nothing, and an owner with no project sees the switcher's New project entry and the settings page's empty state.
seedDefault is read only while enabled is on: with project surfaces off, work can still be scoped to an organization's first project and nothing could create one, so the seed stays. Creating an owner's first project adopts every chat it started without one, in the same transaction, so no chat drops out of the list.
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, each row carrying canManage (whether the caller may change the AI setting). Seeds the owner's Default on first read - best effort, skipped when seedDefault is off, 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?, aiChangesWithoutApproval? } -> 200 { project }; an omitted logo or aiChangesWithoutApproval is left alone, an empty logo clears it; aiChangesWithoutApproval from an API key or a member who is neither owner nor admin is 403 PROJECT_SETTING_FORBIDDEN and nothing is written; a row of another owner is 404 |
DELETE /projects/:id | 200 {}; signed-in sessions only (an API key gets PROJECT_DELETE_FORBIDDEN); inside an organization only its owner and admins may delete (PROJECT_DELETE_FORBIDDEN otherwise); 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_DELETE_FORBIDDEN | 403 | A delete sent with an API key, or by an organization member who is neither its owner nor an admin |
PROJECT_SETTING_FORBIDDEN | 403 | A change to aiChangesWithoutApproval sent with an API key, or by an organization member who is neither its owner nor an admin |
PROJECT_LIMIT | 400 | The create would pass 100 projects for this owner (counted under a per-owner lock, so concurrent creates cannot overshoot) |
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.
AI changes
Some AI tool calls wait for the person's Approve click in the chat: a capability that declares needsApproval and an integration tool its server declares destructive (see AI). Let AI make changes without asking, a switch on /settings/project shown while AI is on, lets the AI make them in this project without the card.
| Default | Off |
| Who can change it | The owner, or an organization admin, from a signed-in session. Members see it read-only; no API key and no AI tool can change it |
| Where it applies | Every AI run working in this project - chat and automations - read when each call is decided and again when it runs. MCP clients work in no project, so it never applies there |
| Record | Each change made without asking is written to the audit log (ai.change_without_approval) before it runs: the tool, the targets the server resolved, the call's fingerprint and the project - never the input, which can carry secrets. When that write fails, the chat shows the card instead |
| Code | projectAllowsAiChangesWithoutApproval (@repo/database), read through aiApprovalWaiver (packages/api/src/ai/approval-policy.ts) |
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.
An AI tool you add that renames, creates or deletes a project belongs in MUTATING_TOOL_DOMAINS (packages/app-core/src/lib/workspace-refresh.ts) as projects: the switcher and the project settings list then re-read when the turn settles, instead of naming the project as it was until a reload.
Mobile store billing
The server side of in-app purchases - the RevenueCat webhook, how a plan's source is decided, what the web checkout does about it, and the reconcile jobs.
Send transactional and marketing email through SMTP, SES, or Resend with @repo/mail, and sync newsletter lists with config.newsletter.