GenerateSaaS

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.

Rowprojects table: id, name, logo, the two nullable owner columns (userId, organizationId), aiChangesWithoutApproval (see AI changes, false by default), createdAt, updatedAt
Ownerthe active organization when tenancy.multiTenant is on, otherwise the signed-in user
Seededa 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
Membershipnone in v1 - every member of an organization sees and edits all of its projects
Logooptional 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
LimitsMAX_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

Scaffold with the projects manifest option, or set projects.enabled to true in packages/config/src/index.ts - the same file the CLI writes.
Reload. The sidebar workspace switcher, the /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.

RouteContract
GET /projects200 { 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/:id200 {}; 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/reachable200 { ids } - the ids a correctly configured client can surface and stop, across every organization the caller belongs to rather than the active one
Error codeHTTPRaised when
LAST_PROJECT400The delete would leave the owner with none
PROJECT_DELETE_FORBIDDEN403A delete sent with an API key, or by an organization member who is neither its owner nor an admin
PROJECT_SETTING_FORBIDDEN403A change to aiChangesWithoutApproval sent with an API key, or by an organization member who is neither its owner nor an admin
PROJECT_LIMIT400The create would pass 100 projects for this owner (counted under a per-owner lock, so concurrent creates cannot overshoot)
NO_ACTIVE_ORGANIZATION400A 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.

DefaultOff
Who can change itThe 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 appliesEvery 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
RecordEach 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
CodeprojectAllowsAiChangesWithoutApproval (@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.

On this page