# FastSvelte Documentation
> FastSvelte is a FastAPI (Python) + SvelteKit starter kit: auth, payments, and multi-tenancy, plus AI-ready usage metering, per-plan credit allowances, and top-ups. Self-hosted, full source code, no platform lock-in. Includes generated TypeScript API clients via Orval, admin and user dashboards, and Docker Compose for local development. Supports both B2C and B2B (multi-tenant with organizations, roles, and invitations) modes.
FastSvelte is a FastAPI (Python) + SvelteKit starter kit: auth, payments, and multi-tenancy, plus AI-ready usage metering, per-plan credit allowances, and top-ups. Self-hosted, full source code, no platform lock-in. Includes generated TypeScript API clients via Orval, admin and user dashboards, and Docker Compose for local development. Supports both B2C and B2B (multi-tenant with organizations, roles, and invitations) modes.
For the full documentation content concatenated into a single file, see [llms-full.txt](https://docs.fastsvelte.dev/llms-full.txt).
# Get Started
# Quick Start
[FastSvelte](https://fastsvelte.dev) is a fullstack SaaS starter kit built with **FastAPI** (Python) and **SvelteKit** (TypeScript). Get it running locally in a few minutes, then browse [Features](https://docs.fastsvelte.dev/features/authentication/index.md) for everything that's included.
The interactive `init.py` script handles setup end to end. Clone the repo, run `init.py`, then create an admin account and start the servers:
```sh
# 1. Clone, then drop the template remote
git clone https://github.com/harunzafer/fastsvelte.git my-project
cd my-project && git remote remove origin
# 2. Interactive setup — sets up your project for you (--dry-run to preview)
uv run init.py
# 3. Create a system admin, then start the backend
cd backend
uv run scripts/create_admin.py
uv run uvicorn app.main:app --reload
# 4. In a new terminal, start the frontend
cd frontend && npm run dev
```
Prerequisites
You'll need [uv](https://docs.astral.sh/uv/#installation) to run `init.py` in the first place, plus **Node.js 22+** and **Docker** (running). `init.py` checks those two first and exits with install instructions if either is missing, so once `uv` is in place you can just run it and fix what it flags.
Open **[localhost:5173](http://localhost:5173)** and log in with the admin you created. The backend and interactive API docs are at [localhost:8000/docs](http://localhost:8000/docs); the landing template runs at [localhost:5174](http://localhost:5174).
## Next steps
- **[Guides](https://docs.fastsvelte.dev/guides/project-setup/index.md)** — set up your project, develop, and ship
- **[Features](https://docs.fastsvelte.dev/features/authentication/index.md)** — auth, billing, AI, multi-tenancy, and more
- **[Architecture](https://docs.fastsvelte.dev/reference/architecture/index.md)** · **[Deployment](https://docs.fastsvelte.dev/deployment/index.md)**
# Guides
# Project Setup
After this guide you'll understand what `init.py` configured, where configuration lives, and how to onboard a teammate.
## What `init.py` does
You run `init.py` once during [Quick Start](https://docs.fastsvelte.dev/index.md). It:
- prompts for your app name, mode (`b2c` / `b2b`), and database;
- generates the `.env` files across `backend/`, `frontend/`, `landing/`, and `backend/db/`;
- starts Docker PostgreSQL and runs migrations (with seed data);
- installs backend (`uv`) and frontend/landing (`npm`) dependencies;
- generates the type-safe API client (see [Type-Safe API Client](https://docs.fastsvelte.dev/guides/orval/index.md)).
Pass `--dry-run` to preview without making changes. In development the email provider defaults to `stub`, so nothing is sent — verification and invitation links appear as `[STUB EMAIL]` blocks in the backend logs (see [Transactional Email](https://docs.fastsvelte.dev/features/email/index.md)). Once setup works, you can safely delete `init.py`; it's only needed once.
## Onboarding teammates
**Only the first developer runs `init.py`.** Everyone else starts from your committed, pre-configured repository.
1. **Push to your own repository:**
```bash
git remote add origin git@github.com:your-org/your-project.git
git push -u origin main
```
1. **Teammates clone and configure:**
- Clone your repository (not the FastSvelte template).
- Install the [prerequisites](https://docs.fastsvelte.dev/index.md) (uv, Node.js 22+, Docker).
- Create `.env` files by copying each `.env.example` (`backend/`, `frontend/`, `landing/`, `backend/db/`).
- Start the database: `docker compose -f backend/docker-compose.yml up db -d`
- Run migrations: `cd backend/db && ./sqitch.sh dev deploy`
- Install deps: `uv sync` in `backend/`, `npm install` in `frontend/` and `landing/`
- Create a local admin: `cd backend && uv run scripts/create_admin.py`
Never commit `.env` files
They contain secrets and are gitignored. Share values through a secure channel (1Password, Doppler, a shared secrets manager).
See [Configuration](https://docs.fastsvelte.dev/reference/configuration/index.md) for the full variable reference.
# Development Workflow
After this guide you can make a change across the stack and run the app. Work in the **DB → Backend → Frontend** order so the API client regenerates against an up-to-date backend.
## Database changes
```bash
cd backend/db
./sqitch.sh add feature_name -n "Description"
# Edit the generated deploy/, revert/, verify/ SQL files
./sqitch.sh dev deploy
```
See [Database](https://docs.fastsvelte.dev/features/database/index.md) for the migration model.
## Backend changes
```bash
cd backend
uv add package_name # add a dependency (--dev for dev-only)
uv run ruff check . # lint
uv run pytest # test
uv run uvicorn app.main:app --reload # run (auto-reloads on change)
```
## Frontend changes
After backend API changes, regenerate the typed client (see [Type-Safe API Client](https://docs.fastsvelte.dev/guides/orval/index.md)):
```bash
cd frontend
npm run generate # regenerate the API client (backend must be running)
npm run format # Prettier
npm run check # type checking
npm run test # tests (also test:unit / test:e2e)
npm run dev # dev server (hot reload)
```
# Tutorials
This section provides step-by-step guides for extending [FastSvelte](https://fastsvelte.dev) with new functionality.
## Adding a New Entity (End-to-End)
This tutorial walks through adding a complete "Projects" feature to demonstrate how to extend every layer of the FastSvelte stack. You'll learn to add database tables, backend APIs, and frontend interfaces.
### Overview
We'll build a Projects feature that allows users to:
- Create, read, update, and delete projects
- Associate projects with the current organization
- Display projects in a list and detail view
### Step 1: Database Schema
First, create a new migration for the projects table.
```bash
cd backend/db
./sqitch.sh add projects -n "Add projects table"
```
Edit the generated migration file `backend/db/deploy/projects.sql`:
```sql
-- Deploy {schema_name}:projects to pg
-- Replace {schema_name} with your actual schema name (set during init.py)
BEGIN;
CREATE TABLE IF NOT EXISTS {schema_name}.project (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
organization_id INTEGER NOT NULL REFERENCES {schema_name}.organization(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES {schema_name}."user"(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_project_organization ON {schema_name}.project(organization_id);
CREATE INDEX IF NOT EXISTS idx_project_user ON {schema_name}.project(user_id);
COMMIT;
```
Create the revert script `backend/db/revert/projects.sql`:
```sql
-- Revert {schema_name}:projects from pg
BEGIN;
DROP INDEX IF EXISTS {schema_name}.idx_project_user;
DROP INDEX IF EXISTS {schema_name}.idx_project_organization;
DROP TABLE IF EXISTS {schema_name}.project;
COMMIT;
```
Apply the migration:
```bash
./sqitch.sh dev deploy
```
### Step 2: Backend Models
Create the Pydantic models in `backend/app/model/project.py`:
```python
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class ProjectEntity(BaseModel):
id: int
name: str
description: str | None = None
organization_id: int
user_id: int
created_at: datetime
updated_at: datetime
class CreateProjectRequest(BaseModel):
name: str
description: str | None = None
class UpdateProjectRequest(BaseModel):
name: str | None = None
description: str | None = None
class ProjectResponse(BaseModel):
id: int
name: str
description: str | None = None
created_at: datetime
updated_at: datetime
```
### Step 3: Repository Layer
Create `backend/app/data/repo/project_repo.py`:
```python
from app.data.repo.base_repo import BaseRepo
from app.model.project import ProjectEntity
class ProjectRepo(BaseRepo):
async def create_project(
self,
name: str,
description: str | None,
user_id: int,
organization_id: int
) -> ProjectEntity:
query = f"""
INSERT INTO {self.schema}.project (name, description, user_id, organization_id)
VALUES ($1, $2, $3, $4)
RETURNING id, name, description, organization_id, user_id, created_at, updated_at
"""
row = await self.fetch_one(query, name, description, user_id, organization_id)
return ProjectEntity(**row)
async def get_project_by_id(self, project_id: int, organization_id: int) -> ProjectEntity | None:
query = f"""
SELECT id, name, description, organization_id, user_id, created_at, updated_at
FROM {self.schema}.project
WHERE id = $1 AND organization_id = $2
"""
row = await self.fetch_one(query, project_id, organization_id)
return ProjectEntity(**row) if row else None
async def get_projects_by_organization(self, organization_id: int) -> list[ProjectEntity]:
query = f"""
SELECT id, name, description, organization_id, user_id, created_at, updated_at
FROM {self.schema}.project
WHERE organization_id = $1
ORDER BY created_at DESC
"""
rows = await self.fetch_all(query, organization_id)
return [ProjectEntity(**row) for row in rows]
async def update_project(
self,
project_id: int,
name: str | None,
description: str | None,
organization_id: int
) -> ProjectEntity | None:
query = f"""
UPDATE {self.schema}.project
SET name = COALESCE($3, name),
description = COALESCE($4, description),
updated_at = now()
WHERE id = $1 AND organization_id = $2
RETURNING id, name, description, organization_id, user_id, created_at, updated_at
"""
row = await self.fetch_one(query, project_id, organization_id, name, description)
return ProjectEntity(**row) if row else None
async def delete_project(self, project_id: int, organization_id: int) -> None:
query = f"DELETE FROM {self.schema}.project WHERE id = $1 AND organization_id = $2"
await self.execute(query, project_id, organization_id)
```
### Step 4: Service Layer
Create `backend/app/service/project_service.py`:
```python
from app.data.repo.project_repo import ProjectRepo
from app.model.project import CreateProjectRequest, ProjectEntity, UpdateProjectRequest
class ProjectService:
def __init__(self, project_repo: ProjectRepo):
self.project_repo = project_repo
async def create_project(
self,
organization_id: int,
user_id: int,
data: CreateProjectRequest
) -> ProjectEntity:
return await self.project_repo.create_project(
data.name, data.description, user_id, organization_id
)
async def list_projects(self, organization_id: int) -> list[ProjectEntity]:
return await self.project_repo.get_projects_by_organization(organization_id)
async def get_project(
self,
project_id: int,
organization_id: int
) -> ProjectEntity | None:
return await self.project_repo.get_project_by_id(project_id, organization_id)
async def update_project(
self,
project_id: int,
organization_id: int,
data: UpdateProjectRequest
) -> ProjectEntity | None:
return await self.project_repo.update_project(
project_id, data.name, data.description, organization_id
)
async def delete_project(self, project_id: int, organization_id: int) -> None:
await self.project_repo.delete_project(project_id, organization_id)
```
### Step 5: API Routes
Create `backend/app/api/route/project_route.py`:
```python
from app.api.middleware.auth_handler import min_role_required
from app.config.container import Container
from app.exception.common_exception import ResourceNotFound
from app.model.project import CreateProjectRequest, ProjectResponse, UpdateProjectRequest
from app.model.role_model import Role
from app.model.user_model import CurrentUser
from app.service.project_service import ProjectService
from dependency_injector.wiring import Provide, inject
from fastapi import APIRouter, Depends
router = APIRouter()
@router.post("", response_model=ProjectResponse, operation_id="createProject")
@inject
async def create_project(
data: CreateProjectRequest,
user: CurrentUser = Depends(min_role_required(Role.MEMBER)),
project_service: ProjectService = Depends(Provide[Container.project_service]),
):
project = await project_service.create_project(
user.organization_id, user.id, data
)
return ProjectResponse.model_validate(project.model_dump())
@router.get("", response_model=list[ProjectResponse], operation_id="listProjects")
@inject
async def list_projects(
user: CurrentUser = Depends(min_role_required(Role.MEMBER)),
project_service: ProjectService = Depends(Provide[Container.project_service]),
):
projects = await project_service.list_projects(user.organization_id)
return [ProjectResponse.model_validate(p.model_dump()) for p in projects]
@router.get("/{project_id}", response_model=ProjectResponse, operation_id="getProject")
@inject
async def get_project(
project_id: int,
user: CurrentUser = Depends(min_role_required(Role.MEMBER)),
project_service: ProjectService = Depends(Provide[Container.project_service]),
):
project = await project_service.get_project(project_id, user.organization_id)
if not project:
raise ResourceNotFound("project", project_id)
return ProjectResponse.model_validate(project.model_dump())
@router.put("/{project_id}", response_model=ProjectResponse, operation_id="updateProject")
@inject
async def update_project(
project_id: int,
data: UpdateProjectRequest,
user: CurrentUser = Depends(min_role_required(Role.MEMBER)),
project_service: ProjectService = Depends(Provide[Container.project_service]),
):
project = await project_service.update_project(
project_id, user.organization_id, data
)
if not project:
raise ResourceNotFound("project", project_id)
return ProjectResponse.model_validate(project.model_dump())
@router.delete("/{project_id}", status_code=204, operation_id="deleteProject")
@inject
async def delete_project(
project_id: int,
user: CurrentUser = Depends(min_role_required(Role.MEMBER)),
project_service: ProjectService = Depends(Provide[Container.project_service]),
):
project = await project_service.get_project(project_id, user.organization_id)
if not project:
raise ResourceNotFound("project", project_id)
await project_service.delete_project(project_id, user.organization_id)
```
### Step 6: Dependency Injection Setup
Update `backend/app/config/container.py` to include the new components:
```python
# 1. Add to imports at the top of the file
from app.data.repo.project_repo import ProjectRepo
from app.service.project_service import ProjectService
# 2. Add to the Container class in the repositories section (around line 47)
project_repo = providers.Singleton(ProjectRepo, db_config=db_config)
# 3. Add to the Container class in the services section (around line 157)
project_service = providers.Factory(
ProjectService,
project_repo=project_repo,
)
```
### Step 7: Register Routes
Update `backend/app/api/router.py` to include the new routes:
```python
# 1. Add to imports at the top
from app.api.route.project_route import router as project_router
# 2. Add to include_all_routers() function (around line 31)
app.include_router(project_router, prefix="/projects", tags=["Projects"])
```
### Step 8: Test the Backend API
Before building the frontend, test your new API endpoints to ensure they work correctly.
**1. Start the backend server:**
```bash
cd backend
uv run uvicorn app.main:app --reload
```
**2. Create a test file** at `backend/http/14_projects.http`:
```http
### FastSvelte Projects API Tests
### Login first using 01_auth.http - cookies persist across all files
### Variables loaded from .env file in this directory
@baseUrl = {{$dotenv BASE_URL}}
### ============================================
### CREATE PROJECT
### ============================================
### Create Project
POST {{baseUrl}}/projects
Content-Type: application/json
{
"name": "My First Project",
"description": "A sample project for testing"
}
###
### ============================================
### LIST & GET PROJECTS
### ============================================
### List All Projects
GET {{baseUrl}}/projects
###
### Get Specific Project
# @prompt projectId Project ID to retrieve
GET {{baseUrl}}/projects/{{projectId}}
### ============================================
### UPDATE PROJECT
### ============================================
### Update Project
# @prompt projectId Project ID to update
PUT {{baseUrl}}/projects/{{projectId}}
Content-Type: application/json
{
"name": "Updated Project Name",
"description": "Updated description"
}
### ============================================
### DELETE PROJECT
### ============================================
### Delete Project
# @prompt projectId Project ID to delete
DELETE {{baseUrl}}/projects/{{projectId}}
### ============================================
### TEST ERROR CASES
### ============================================
### Get non-existent project (should return 404)
GET {{baseUrl}}/projects/99999
###
### Update non-existent project (should return 404)
PUT {{baseUrl}}/projects/99999
Content-Type: application/json
{
"name": "Updated",
"description": "Updated description"
}
###
### Delete non-existent project (should return 404)
DELETE {{baseUrl}}/projects/99999
```
**3. Authenticate first:**
Before testing the Projects API, you need to log in to get an authenticated session.
If you're using VSCode with the [REST Client extension](https://marketplace.visualstudio.com/items?itemName=humao.rest-client):
1. Open `backend/http/01_auth.http`
1. Run the "Register" or "Login" request to authenticate
1. The session cookie will persist across all `.http` files
1. Now open `backend/http/14_projects.http` and run the requests
**4. Test your API:**
Click "Send Request" above each test in the `14_projects.http` file to test all CRUD operations.
**Alternative: Test with curl:**
```bash
# First, login and save the session cookie
curl -X POST http://localhost:8000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "your-email@example.com", "password": "your-password"}' \
-c cookies.txt
# Create a project (using saved cookies)
curl -X POST http://localhost:8000/api/projects \
-H "Content-Type: application/json" \
-d '{"name": "My First Project", "description": "A sample project"}' \
-b cookies.txt
# List all projects
curl http://localhost:8000/api/projects -b cookies.txt
# Get a specific project (replace 1 with actual ID)
curl http://localhost:8000/api/projects/1 -b cookies.txt
# Update a project (replace 1 with actual ID)
curl -X PUT http://localhost:8000/api/projects/1 \
-H "Content-Type: application/json" \
-d '{"name": "Updated Name", "description": "Updated description"}' \
-b cookies.txt
# Delete a project (replace 1 with actual ID)
curl -X DELETE http://localhost:8000/api/projects/1 -b cookies.txt
```
View API Documentation
Visit `http://localhost:8000/docs` to see the auto-generated OpenAPI documentation for all your endpoints, including the new Projects API.
### Step 9: Generate Frontend API Client
After adding the backend routes, regenerate the TypeScript API client:
```bash
cd frontend
npm run generate
```
This creates the API functions in `src/lib/api/gen/` that you'll use in the frontend.
### Step 10: Build Frontend Components
Create the projects list page at `frontend/src/routes/(protected)/projects/+page.svelte`:
```html
```
### Step 12: Test the Complete Feature
**1. Start the database** (if not already running):
```bash
docker compose up db -d
```
**2. Start the backend**:
```bash
cd backend
uv run uvicorn app.main:app --reload
```
**3. Start the frontend** (in a new terminal):
```bash
cd frontend
npm run dev
```
**4. Navigate to projects**: Visit `http://localhost:5173/projects`
**5. Test CRUD operations**:
- Create a new project
- View the projects list
- Edit existing projects
- Delete projects
### Summary
You've successfully added a complete "Projects" feature that demonstrates:
- **Database design**: Migration with proper foreign keys and constraints
- **Backend architecture**: Repository pattern, service layer, API routes
- **Dependency injection**: Proper container configuration
- **API design**: RESTful endpoints with proper HTTP status codes
- **Frontend integration**: API client generation and UI components
- **Security**: Organization-based data isolation and authentication
This pattern can be applied to add any new entity to your FastSvelte application. The key principles are:
1. Start with the database schema
1. Build from the data layer up (repository → service → routes)
1. Configure dependency injection
1. Generate and use the API client on the frontend
1. Build UI components following the existing design patterns
# Metering a Custom Feature
After this guide you can put any action behind a per-plan usage limit — and, optionally, let credit top-ups extend it the way AI tokens do. See [Plans & Usage](https://docs.fastsvelte.dev/features/plans-and-usage/index.md) for the engine this builds on.
## 1. Define the feature
Add it to both the `FeatureKey` enum and the `PlanFeatures` model in `backend/app/model/plan_model.py`:
```python
class FeatureKey(str, Enum):
MAX_NOTES = "max_notes"
TOKEN_LIMIT = "token_limit"
ENABLE_AI = "enable_ai"
MAX_PROJECTS = "max_projects" # new
class PlanFeatures(BaseModel):
max_notes: int | None
token_limit: int | None
enable_ai: bool | None
max_projects: int | None # new
```
## 2. Set limits per plan
Add `max_projects` to each plan's `features` map — via the admin Plans page or seed data (see [Plans & Usage](https://docs.fastsvelte.dev/features/plans-and-usage/#configuring-limits)).
## 3. Enforce the limit
Wrap the action with the generic usage service — check before, record after:
```python
if not await usage_service.check_quota_for(org_id, FeatureKey.MAX_PROJECTS, 1):
raise QuotaExceeded(FeatureKey.MAX_PROJECTS)
project = await project_service.create(...)
await usage_service.update_usage(org_id, FeatureKey.MAX_PROJECTS, 1)
```
That's all it takes to meter a feature against the plan limit, reset each billing period.
## 4. (Optional) Let credits top it up
Today only AI tokens fall back to the [credit balance](https://docs.fastsvelte.dev/features/ai-billing/index.md). To let credits extend another feature, mirror what `AiUsageBillingService` does: when the per-period limit is exhausted, check and debit the org's credit balance before blocking the action.
Two caveats, since the credit system is currently AI-token-specific:
- The balance is a single **token-denominated** pool. If you want a separate balance per feature (e.g. "project credits"), add a per-feature balance rather than reusing the token pool.
- Decide the unit. Reusing the token pool only makes sense if your feature is denominated the same way.
So: metering any feature is built in; credit top-ups for non-AI features are a small, deliberate extension.
# Swapping the LLM Provider
FastSvelte ships OpenAI as the default LLM, but no route, service, or billing code ever imports a provider SDK directly — they depend only on a small `LLMClient` seam (see [AI Integration](https://docs.fastsvelte.dev/features/ai/#the-llmclient-interface)). To switch providers you write one class implementing that interface and repoint the dependency-injection container at it. Nothing else in the backend changes.
## The contract
`backend/app/service/llm_client.py`:
```python
class LLMClient(Protocol):
async def structured(self, messages: list[dict], model: Type[T]) -> tuple[T, TokenUsage]: ...
def stream(self, messages: list[dict]) -> AsyncIterator[str | TokenUsage]: ...
```
- **`structured()`** returns a parsed Pydantic model plus the call's `TokenUsage`.
- **`stream()`** yields plain-text chunks and, as its **final item**, the call's `TokenUsage` (the caller accumulates text for the UI and forwards that final usage to billing).
`messages` is an OpenAI-style `list[dict]` (roles `system` / `user` / `assistant`); each adapter is responsible for translating it into its own provider's request shape.
## Claude (Anthropic) — a complete recipe
Add `anthropic` to the backend dependencies, then create `backend/app/service/claude_client.py`:
```python
from typing import AsyncIterator, Type, TypeVar
from anthropic import AsyncAnthropic, NOT_GIVEN
from pydantic import BaseModel
from app.model.llm_usage_model import TokenUsage
from app.service.llm_client import LLMClient
T = TypeVar("T", bound=BaseModel)
PROVIDER = "anthropic"
def _split_system(messages: list[dict]) -> tuple[str | object, list[dict]]:
# Anthropic takes the system prompt as a top-level arg, not a role in `messages`.
system = next((m["content"] for m in messages if m["role"] == "system"), NOT_GIVEN)
chat = [m for m in messages if m["role"] != "system"]
return system, chat
class ClaudeClient(LLMClient):
def __init__(self, model: str, max_tokens: int = 4096, api_key: str | None = None):
self.client = AsyncAnthropic(api_key=api_key)
self.model = model
self.max_tokens = max_tokens
async def structured(self, messages: list[dict], model: Type[T]) -> tuple[T, TokenUsage]:
system, chat = _split_system(messages)
response = await self.client.messages.parse(
model=self.model,
max_tokens=self.max_tokens,
system=system,
messages=chat,
output_format=model, # Pydantic class -> response.parsed_output
)
return response.parsed_output, self._usage(response.usage)
async def stream(self, messages: list[dict]) -> AsyncIterator[str | TokenUsage]:
system, chat = _split_system(messages)
async with self.client.messages.stream(
model=self.model,
max_tokens=self.max_tokens,
system=system,
messages=chat,
) as stream:
async for text in stream.text_stream:
yield text
final = await stream.get_final_message()
yield self._usage(final.usage)
def _usage(self, usage) -> TokenUsage:
# Anthropic reports input/output separately; total is the sum.
return TokenUsage(
provider=PROVIDER,
model=self.model,
input_tokens=usage.input_tokens,
output_tokens=usage.output_tokens,
total_tokens=usage.input_tokens + usage.output_tokens,
)
```
The shape mirrors the shipped `OpenAIClient`: `structured()` uses the SDK's structured-output helper (`messages.parse` with a Pydantic `output_format`), and `stream()` yields text deltas then a final `TokenUsage`. The only provider-specific work is lifting the `system` role out of `messages` (Anthropic takes it as a top-level argument) and supplying `max_tokens`, which Anthropic requires.
## Wire it in
Point the dependency-injection container at the new class. In `backend/app/config/container.py`, swap the `openai_client` provider for a `claude_client` and inject it wherever `openai_client` was wired (the `llm_client=...` argument):
```python
claude_client = providers.Singleton(
ClaudeClient,
model=settings.anthropic_model, # e.g. "claude-opus-4-8"
api_key=settings.anthropic_api_key,
)
```
Add `anthropic_api_key` / `anthropic_model` to `Settings` (mirroring the existing OpenAI fields), which read `FS_ANTHROPIC_API_KEY` / `FS_ANTHROPIC_MODEL` from the environment.
## Add the pricing row
Cost is computed from the `model_price` table, so **add a row for the Claude model under provider `anthropic`** or cost calculation fails — see [AI Usage & Credit Billing](https://docs.fastsvelte.dev/features/ai-billing/#model-pricing). Current Claude pricing (per 1M tokens, input / output):
| Model | Input / 1M | Output / 1M |
| ------------------- | ---------- | ----------- |
| `claude-opus-4-8` | $5.00 | $25.00 |
| `claude-sonnet-4-6` | $3.00 | $15.00 |
| `claude-haiku-4-5` | $1.00 | $5.00 |
## Other providers
**Gemini, LiteLLM, or any other provider** follow the exact same shape: one class implementing `structured()` + `stream()`, mapping the provider's usage fields into `TokenUsage`, wired via `container.py`, with a matching `model_price` row. The copilot, billing, routes, and UI are untouched.
## Next steps
- **[AI Integration](https://docs.fastsvelte.dev/features/ai/index.md)** — the `LLMClient` seam, the sample copilot, and streaming.
- **[AI Usage & Credit Billing](https://docs.fastsvelte.dev/features/ai-billing/index.md)** — how every call is metered and billed.
# Replacing the Demo
FastSvelte ships an AI copilot demo (note Improve / Summarize) to show the patterns. After this guide you can strip it and start clean on your own product.
## 1. Plan your application
- What is your main domain model? (Projects, Tasks, Documents, …)
- What actions can users perform? (create, edit, share, export, …)
- What billing model will you use? (per-seat, usage-based, tiered, …)
## 2. Build your features
Follow **[Adding a Feature](https://docs.fastsvelte.dev/guides/adding-a-feature/#adding-a-new-entity-end-to-end)** to add an entity end to end (schema → repository → service → route → UI), then apply the same pattern to your own domain.
## 3. Remove the demo code
The AI billing/usage infrastructure (`llm_client.py`, `openai_client.py`, and the usage/credit services + repos) is reusable — keep it if you want AI features of your own (see [AI Copilot](https://docs.fastsvelte.dev/features/ai/index.md)).
**Backend:**
```bash
cd backend
rm app/model/note_model.py app/model/copilot_model.py
rm app/service/note_service.py app/service/copilot_service.py
rm app/api/route/note_route.py
rm app/data/repo/note_repo.py
```
**Frontend:**
```bash
cd frontend
rm -rf src/routes/(protected)/notes
```
**Database** — drop the note table:
```bash
cd backend/db
./sqitch.sh add remove_note_table -n "Remove note demo table"
```
Edit `deploy/remove_note_table.sql`:
```sql
-- Deploy fastsvelte:remove_note_table to pg
BEGIN;
DROP TABLE IF EXISTS fastsvelte.note CASCADE;
COMMIT;
```
Fill `revert/remove_note_table.sql` with the original `CREATE TABLE` from `deploy/001_schema.sql`, then `./sqitch.sh dev deploy`.
**Optional** — if you're not using AI at all:
```bash
cd backend
uv remove openai
```
# Type-Safe API Client (Orval)
FastSvelte keeps the frontend and backend in sync with a TypeScript API client **generated from the backend's OpenAPI spec** by Orval. Change a Pydantic model or route, regenerate, and the TypeScript compiler catches any breakage at compile time.
## Regenerate after backend changes
```bash
cd frontend
npm run generate # reads the OpenAPI spec and regenerates the client (backend must be running)
```
Generated code lives in `frontend/src/lib/api/gen/` — **don't edit it by hand**; it's overwritten on every generate.
## How it works
1. Define Pydantic models and routes with `response_model` and `operation_id`:
```python
@router.post("", response_model=NoteResponse, operation_id="createNote")
async def create_note(data: CreateNoteRequest, ...): ...
```
- `response_model` → the response type in the OpenAPI spec
- `operation_id` → the generated TypeScript function name
1. FastAPI generates the OpenAPI spec from the decorators and models.
1. Orval reads the spec and emits typed functions and models.
1. Import and use them with full type safety:
```typescript
import { createNote } from "$lib/api/gen/notes";
import type { CreateNoteRequest, NoteResponse } from "$lib/api/gen/model";
const note: NoteResponse = await createNote({ title: "My Note", content: "..." });
```
Change the backend → run `npm run generate` → the compiler flags any frontend code that no longer matches.
# Upgrading Dependencies
After this guide you can keep dependencies current and secure using Dependabot plus a verify workflow.
## Upgrade policy
FastSvelte ships a [Dependabot config](https://github.com/) (`.github/dependabot.yml`) so routine upgrades are automated and reviewable:
- **Automated, weekly:** Dependabot opens **grouped** PRs once a week per ecosystem — `npm` (frontend), `npm` (landing), and `uv` (backend). The backend uses a 7-day cooldown so brand-new releases settle before they're proposed.
- **Minor & patch upgrades:** handled by those weekly PRs. Review and merge once CI passes (`backend.yml`, `frontend.yml`, `landing.yml`).
- **Major versions:** **excluded** from Dependabot (`version-update:semver-major` is ignored) and done deliberately, one component at a time, since they may require code changes — follow the major-version workflow below.
- **Security updates:** fast-track immediately, outside the weekly cadence.
- **Version pinning:** `pyproject.toml` and `package.json` declare lower-bound (`>=`) ranges; the lockfiles (`uv.lock`, `package-lock.json`) pin exact versions and are committed — so installs are reproducible while ranges stay flexible.
## Backend dependencies (Python)
FastSvelte uses `uv` to manage Python dependencies.
```bash
cd backend
# Upgrade all dependencies (or a specific one)
uv sync --upgrade
uv add --upgrade package_name
# Verify compatibility
uv run pytest
```
**Note:** Smoke tests in `backend/test/smoke/` verify app startup, database connectivity, and API functionality. Database tests require PostgreSQL running (`docker compose up db -d`) but skip gracefully if unavailable.
## Frontend dependencies (npm)
The frontend and landing page use `package.json` + `package-lock.json`.
```bash
# Frontend
cd frontend
npm outdated # check what's outdated
npm update # update within semver ranges
npm run build && npm run check && npm run lint && npm run test
# Landing (simpler, no tests by default)
cd ../landing
npm outdated && npm update
npm run build && npm run check && npm run lint
```
### Major version upgrades
```bash
npm install -g npm-check-updates # one-time
cd frontend # or cd landing
ncu # preview
ncu -u # bump package.json to latest
npm install
npm run build && npm run check && npm run lint
npm run test # frontend only (landing has no tests by default)
```
**Important:**
- Commit `package-lock.json` after upgrades.
- Test thoroughly after major updates, and upgrade one component at a time (backend, frontend, landing) for easier troubleshooting.
- Frontend smoke tests live in `frontend/src/tests/smoke/` and run with `npm run test`.
- To add tests to landing, run `npx sv add vitest`.
# Getting Updates
[FastSvelte](https://fastsvelte.dev) is yours to customize and extend. You can stay connected to receive bug fixes and improvements, or disconnect completely and own your codebase.
## Minimizing Conflicts
**Extend rather than modify** to reduce merge conflicts. When you create new files instead of editing existing FastSvelte code, conflicts are limited to a few predictable integration points.
**Extension Pattern for Fewer Conflicts:**
- New routes, services, repositories in their own files
- New frontend pages and components
- New database migrations
- Conflicts are still expected in files such as `container.py` or `routes.py` where new components are registered. But manageable.
## Update Methods
### 1. Merge (Recommended)
Keep FastSvelte as an upstream remote and merge updates:
```bash
# During initial setup (instead of removing origin)
git clone https://github.com/harunzafer/fastsvelte.git my-project
cd my-project
git remote rename origin upstream
git remote add origin
git push -u origin main
# If you already removed origin, add upstream back
git remote add upstream https://github.com/harunzafer/fastsvelte.git
# Later, when you want updates
git fetch upstream
# View available updates
git log --oneline HEAD..upstream/main
# Merge updates
git merge upstream/main
# Resolve conflicts if any (usually in container.py, routes.py)
# After resolving, commit the merge
# Test the changes
cd backend && pytest # For backend changes
cd frontend && npm run build && npm run test # For frontend changes
cd landing && npm run build && npm run check # For landing page changes
# Push to your repository
git push origin main
```
### 2. Rebase
An alternative to merge that replays your commits on top of the upstream changes, resulting in a linear history:
```bash
# Set up upstream remote (same as merge method)
git remote add upstream https://github.com/harunzafer/fastsvelte.git
# Fetch updates
git fetch upstream
# Rebase your branch onto upstream
git rebase upstream/main
# Resolve any conflicts, then continue
git rebase --continue
# Push to your repository (force required since history was rewritten)
git push --force-with-lease origin main
```
**Best for:** Projects early in development with few commits, or when you want a clean linear history.
**Caution:** Avoid rebasing if you have a shared branch with collaborators — it rewrites history.
### 3. Cherry-pick
For selective updates or when you've modified core files extensively:
```bash
# Cherry-pick specific commits
git cherry-pick
# Or a range of commits
git cherry-pick ..
# Resolve conflicts and continue
git cherry-pick --continue
```
### 4. Manual Copy
Remove the FastSvelte remote and manually copy changes when needed:
```bash
# During initial setup (default approach in Getting Started)
git clone https://github.com/harunzafer/fastsvelte.git my-project
cd my-project
git remote remove origin
git remote add origin
git push -u origin main
# Later, check GitHub for updates you want
# Manually copy relevant changes to your project
```
**Best for:** Projects with heavy customization or teams that prefer full control.
## Best Practices
1. **Always test updates in development first** - Never update production directly
1. **Create a backup branch** before major updates
1. **Review release notes** to understand what changed
______________________________________________________________________
**Next Steps:**
- [Development Guide](https://docs.fastsvelte.dev/guides/development-workflow/index.md) - Build your application
- [Deployment](https://docs.fastsvelte.dev/deployment/index.md) - Deploy to production
# Features
# Authentication
FastSvelte ships session-based authentication out of the box: email/password and Google OAuth login, email verification, password reset, and role-based access control. Sessions are server-side and carried in an HTTP-only cookie.
## Roles
Roles are precedence-ordered (`backend/app/model/role_model.py`). Endpoints are guarded by `min_role_required(Role.X)` — a user satisfies the requirement if their role's precedence is **at least** the required role's.
| Role | DB name | Precedence |
| -------------- | ----------- | ---------- |
| `READONLY` | `readonly` | 0 |
| `MEMBER` | `member` | 1 |
| `ORG_ADMIN` | `org_admin` | 2 |
| `SYSTEM_ADMIN` | `sys_admin` | 3 |
## Sessions
On login the backend creates a server-side session and sets a cookie (`backend/app/util/cookie_util.py`):
- **Name** — `session_id` (configurable via `FS_SESSION_COOKIE_NAME`).
- **`HttpOnly`** — always, so JavaScript can't read the token.
- **`Secure`** — on in `beta`/`prod`, off in `dev`.
- **`SameSite`** — `strict` in `beta`/`prod`, `lax` in `dev`.
- **Max age** — `FS_SESSION_COOKIE_MAX_AGE` (default 24h).
`POST /auth/logout` invalidates the session server-side and clears the cookie. Expired sessions are pruned by the cron job (`/cron`, gated by `FS_CRON_SECRET`; retention `FS_CRON_SESSION_RETENTION_DAYS`, default 7 days).
## Email & password
| Endpoint | Purpose |
| ----------------------- | ------------------------------------------------- |
| `POST /auth/signup` | Create a user (B2C — see [modes](#b2c-vs-b2b)) |
| `POST /auth/signup-org` | Create an organization + its admin (B2C) |
| `POST /auth/login` | Log in; sets the session cookie |
| `POST /password/forgot` | Email a password-reset link |
| `POST /password/reset` | Reset the password with a token |
| `POST /password/update` | Change the password while logged in (`READONLY`+) |
Login requires a **verified email** — unverified accounts get `EmailNotVerified`. Password-reset emails are sent through the configured [email provider](https://docs.fastsvelte.dev/features/email/index.md).
## Google OAuth
| Endpoint | Purpose |
| -------------------------------------- | --------------------------------------------------------------- |
| `GET /auth/oauth/google/authorize-url` | Returns the Google authorization URL |
| `GET /auth/oauth/google/callback` | Handles the redirect, creates the session, redirects to the app |
The flow uses a signed `state` parameter for CSRF protection (`backend/app/util/oauth_util.py`), validated on callback. Errors (cancelled, invalid state, etc.) redirect back to `/login?error=...`. For Google Cloud credentials and redirect-URI setup, see the [Integrations guide](https://docs.fastsvelte.dev/features/google-oauth/index.md); to add other providers, extend `oauth_util.py` and add routes in `auth_route.py`.
## B2C vs B2B
Signup behavior depends on the app mode:
- **B2C** — public `signup` and `signup-org` are open.
- **B2B** — both return `404`; users join only via [organization invitations](https://docs.fastsvelte.dev/features/multi-tenancy/index.md). Only a `SYSTEM_ADMIN` creates organizations.
See **[B2B Mode](https://docs.fastsvelte.dev/features/multi-tenancy/index.md)** for the full multi-tenant model.
## Configuration
```bash
# Google OAuth
FS_GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com"
FS_GOOGLE_CLIENT_SECRET="GOCSPX-your-google-client-secret"
# Signs the OAuth state parameter (CSRF protection)
FS_JWT_SECRET_KEY="your-jwt-secret-key"
# Sessions
FS_SESSION_COOKIE_NAME="session_id"
FS_SESSION_COOKIE_MAX_AGE=86400
# Session-cleanup cron
FS_CRON_SECRET="your-secure-cron-secret"
FS_CRON_SESSION_RETENTION_DAYS=7
```
## Next steps
- **[Integrations](https://docs.fastsvelte.dev/features/google-oauth/index.md)** — Google OAuth provider setup and email configuration.
- **[B2B Mode](https://docs.fastsvelte.dev/features/multi-tenancy/index.md)** — organizations, invitations, and roles.
# Google OAuth
FastSvelte supports social login via Google OAuth out of the box, alongside [email/password authentication](https://docs.fastsvelte.dev/features/authentication/index.md). The architecture extends to other providers.
## Setup
1. Go to [console.cloud.google.com](https://console.cloud.google.com) and create (or select) a project.
1. Enable the Google OAuth2 API.
1. Create an **OAuth 2.0 Client ID** (Web application).
1. Add the authorized redirect URI: `http://localhost:8000/auth/oauth/google/callback` (and your production equivalent).
JWT secret for OAuth state
OAuth flows use a JWT secret to sign the `state` parameter (CSRF protection). `init.py` auto-generates it. To generate manually:
```bash
python -c "import secrets; print(secrets.token_urlsafe(32))"
```
## Configuration
```bash
# backend/.env
FS_GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com"
FS_GOOGLE_CLIENT_SECRET="GOCSPX-your-google-client-secret"
# Signs the OAuth state parameter (CSRF protection)
FS_JWT_SECRET_KEY="your-jwt-secret-key-here"
```
## The Login Flow
1. **Frontend** requests `GET /auth/oauth/google/authorize-url`.
1. **Backend** returns the Google authorization URL (with a signed `state`).
1. **User** authenticates with Google.
1. **Google** redirects to `GET /auth/oauth/google/callback` with an authorization code.
1. **Backend** validates `state`, exchanges the code, creates/loads the user, and sets the session cookie.
1. **Frontend** lands logged in. OAuth errors redirect to `/login?error=...`.
## Adding More Providers
To add GitHub, Microsoft, etc.:
1. Extend the helpers in `backend/app/util/oauth_util.py`.
1. Add provider routes in `backend/app/api/route/auth_route.py`.
1. Add the provider credentials to `backend/app/config/settings.py`.
## Troubleshooting
- **Redirect URI mismatch** — it must match the provider config exactly (scheme, host, port, path).
- **Invalid client** — recheck the client ID/secret.
- **HTTPS** — most providers require HTTPS redirect URIs in production.
See **[Authentication](https://docs.fastsvelte.dev/features/authentication/index.md)** for sessions, roles, and the rest of the auth model.
# Billing & Subscriptions
FastSvelte handles **subscription billing** through Stripe's **Customer Portal** — no custom billing UI to build. Subscription state is **webhook-driven**: Stripe is the source of truth, and the backend updates the database only from webhook events sent to `/webhooks/stripe`. Three columns hold the link: `organization.stripe_customer_id`, `plan.stripe_product_id`, and `organization_plan` (status, period, subscription id).
## Setup
### 1. API key
Create a Stripe account and a [sandbox](https://docs.stripe.com/sandboxes/dashboard/manage#create-a-sandbox) for development, then copy your **Secret key** (Developers → API Keys; `sk_test_...` in sandbox) into `backend/.env`:
```bash
FS_STRIPE_API_KEY=sk_test_YOUR_KEY_HERE
```
Only the secret key is needed — the portal handles all payment UI. Never commit it.
### 2. Create products
In **Products → Add Product**, create your tiers and copy each **Product ID** (`prod_...`):
- **Free tier** — a single **monthly** price; must be **$0** (auto-provisioned on first login).
- **Paid tiers** — **monthly** and **annual** prices in the same currency.
No public free tier?
You still need a $0 default product for auto-provisioning — name it "Default" and simply don't add it to the portal, so users can't select it.
### 3. Link products to plans
The kit seeds three plans (Free, Professional, Premium). At **`/admin/plans`**, edit each and paste its **Stripe Product ID**. The default plan (Free) must map to your $0 product — it's validated and auto-assigned to new users.
### 4. Configure the Customer Portal
In **Settings → Billing → Customer portal**: enable "customers can switch plans" and add the products/prices you want to offer; enable updating payment methods and viewing invoices, and optionally cancellation. Save.
## Local development
Forward Stripe webhooks to your backend with the [Stripe CLI](https://docs.stripe.com/stripe-cli/install):
```bash
stripe login
stripe listen --forward-to localhost:8000/webhooks/stripe
```
Copy the printed `whsec_...` into `backend/.env`, then restart the backend (keep `stripe listen` running in a second terminal):
```bash
FS_STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxx
```
## Testing
Use Stripe [test cards](https://stripe.com/docs/testing) in sandbox (any future expiry, any CVC and postal code):
| Card | Result |
| --------------------- | --------- |
| `4242 4242 4242 4242` | Success |
| `4000 0000 0000 0002` | Declined |
| `4000 0025 0000 3155` | 3D Secure |
## Going live
In **Developers → Webhooks**, add an endpoint at `https://api.yourdomain.com/webhooks/stripe` subscribed to these events, and copy its signing secret to `FS_STRIPE_WEBHOOK_SECRET`:
- `customer.subscription.created` / `updated` / `deleted`
- `checkout.session.completed` — fulfills [AI credit-pack](https://docs.fastsvelte.dev/features/ai-billing/index.md) purchases
Then switch to **Live mode**: recreate products with live pricing (free tier still $0), use live `sk_live_...` keys, update `/admin/plans` with the live Product IDs, configure the portal in live mode, and test a real upgrade.
## Troubleshooting
**Free subscription didn't sync (dev).** If you logged in before `stripe listen` was running, the free subscription exists in Stripe but not your database ("No active plan found"). Fix it from `/billing` → **Manage Subscription** → in the portal click **Cancel subscription**, then **Don't cancel** — that fires `customer.subscription.updated` and syncs. (Or resend the original event from **Stripe → Events**.)
## Related
[Plans & Usage](https://docs.fastsvelte.dev/features/plans-and-usage/index.md) · [AI Usage & Credit Billing](https://docs.fastsvelte.dev/features/ai-billing/index.md) · [Deployment](https://docs.fastsvelte.dev/deployment/index.md)
# Plans & Usage
FastSvelte meters usage and enforces per-feature limits through one generic system. The same engine powers the note quota and the AI token allotment — and you can extend it to anything.
## How it works
- **Plans define limits.** Each plan carries a `features` map (typed by `PlanFeatures` + the `FeatureKey` enum in `backend/app/model/plan_model.py`). The kit ships `max_notes`, `token_limit`, and `enable_ai`.
- **Usage is metered per billing period.** `OrganizationUsageService` tracks each org's consumption per `FeatureKey` against the plan's limit, scoped to the current subscription period — it resets when the period rolls over.
- **Limits are enforced.** Call `check_quota_for(...)` before an action and `update_usage(...)` after:
```python
if not await usage_service.check_quota_for(org_id, FeatureKey.MAX_NOTES, 1):
raise QuotaExceeded(FeatureKey.MAX_NOTES)
# ... perform the action ...
await usage_service.update_usage(org_id, FeatureKey.MAX_NOTES, 1)
```
The note demo does exactly this on create and delete.
## Configuring limits
Set each plan's limits in its `features` map via the admin Plans page or seed data — see [Admin & User Dashboards](https://docs.fastsvelte.dev/features/admin-dashboards/index.md). How plans map to Stripe products is covered in [Billing & Subscriptions](https://docs.fastsvelte.dev/features/billing/index.md).
## Add your own metered feature
Metering a new feature is a small change: add a `FeatureKey` + `PlanFeatures` field, set limits per plan, and wrap your action with `check_quota_for` / `update_usage`. Full walkthrough: **[Metering a Custom Feature](https://docs.fastsvelte.dev/guides/metering-a-custom-feature/index.md)**.
## AI usage
The AI token allotment (`token_limit`) is just one metered feature — but it adds a paid **credit top-up** layer on top. See [AI Usage & Credit Billing](https://docs.fastsvelte.dev/features/ai-billing/index.md).
# AI Integration
FastSvelte ships a working, end-to-end AI feature so you can build your own on top of it instead of from scratch: a small provider-agnostic `LLMClient` seam, an OpenAI implementation, and a sample **note copilot** (Improve / Summarize) that streams its output to the UI. Every call is metered and billed — see **[AI Usage & Credit Billing](https://docs.fastsvelte.dev/features/ai-billing/index.md)**.
## Setup
Configure the OpenAI key and model in `backend/.env`:
```bash
FS_OPENAI_API_KEY="sk-proj-your-openai-api-key"
FS_OPENAI_MODEL="gpt-5-mini"
```
`FS_OPENAI_MODEL` defaults to **`gpt-5-mini`**. The model name flows from settings into the client provider in `backend/app/config/container.py`:
```python
openai_client = providers.Singleton(
OpenAIClient,
model=settings.openai_model,
temperature=0.1,
api_key=settings.openai_api_key,
)
```
Whatever model you set **must have a row in the `model_price` table**, or usage-cost calculation fails — see [AI Usage & Credit Billing](https://docs.fastsvelte.dev/features/ai-billing/#model-pricing).
## The `LLMClient` interface
App code never imports the OpenAI SDK directly. It depends on a small `Protocol` in `backend/app/service/llm_client.py`:
```python
class LLMClient(Protocol):
async def structured(
self, messages: list[dict], model: Type[T]
) -> tuple[T, TokenUsage]: ...
def stream(
self, messages: list[dict]
) -> AsyncIterator[str | TokenUsage]: ...
```
- **`structured()`** returns a parsed Pydantic model plus the call's `TokenUsage`. Usage travels with every response because every call is billable.
- **`stream()`** yields plain-text chunks and, as its **final item**, the call's `TokenUsage` (captured from the terminal stream event). The caller accumulates text for the UI and forwards that final `TokenUsage` to billing.
This seam is the whole point: swap providers by writing another `LLMClient`, and nothing else in the backend changes.
## OpenAI implementation
`backend/app/service/openai_client.py` implements `LLMClient` against the OpenAI **Responses API** (`client.responses.parse` for structured output, `client.responses.stream` for streaming) — not the older Chat Completions surface. Token usage is read from the response and returned as `TokenUsage(provider, model, input_tokens, output_tokens, total_tokens)`.
## The sample copilot
`backend/app/service/copilot_service.py` exposes two actions, **Improve** and **Summarize**, each accepting an optional `tone`. They build a system+user message pair and return `llm_client.stream(...)`.
They're surfaced on the note routes in `backend/app/api/route/note_route.py`:
| Endpoint | Body | Role | Response |
| ------------------------------------ | --------------------- | -------- | --------------------- |
| `POST /notes/{id}/copilot/improve` | `{ "tone"?: string }` | `MEMBER` | streamed `text/plain` |
| `POST /notes/{id}/copilot/summarize` | `{ "tone"?: string }` | `MEMBER` | streamed `text/plain` |
Each handler loads the note (404 if missing), checks the org has AI capacity before streaming (estimated from the note length), then returns a `StreamingResponse`. The stream is wrapped by the billing service so the final usage is recorded automatically — see [AI Usage & Credit Billing](https://docs.fastsvelte.dev/features/ai-billing/index.md).
## Streaming to the frontend
The frontend consumes the `text/plain` stream incrementally in `frontend/src/lib/api/copilotStream.ts`, appending chunks as they arrive so the user sees output render live. The copilot toolbar wires the Improve / Summarize buttons to these calls.
## Adding your own AI action
1. Add a method to `CopilotService` (or a new service) that builds messages and calls `llm_client.stream(...)` or `.structured(...)`.
1. Add a route that checks capacity, then wraps the stream with `AiUsageBillingService.stream_and_record(...)` (streaming) or calls `record_llm_usage(...)` after a `structured()` call.
1. Regenerate the typed API client and wire the UI.
Billing is not optional plumbing you add later — route every call through `AiUsageBillingService` so usage is metered consistently.
## Using a different provider
OpenAI is the shipped implementation. To swap it, write one class implementing the `LLMClient` interface against your provider's SDK and point `container.py` at it — no route, service, billing, or UI code changes, since that seam is all the rest of the backend depends on. See **[Swapping the LLM Provider](https://docs.fastsvelte.dev/guides/swapping-llm-provider/index.md)** for a complete, drop-in Claude (Anthropic) recipe and the Gemini / LiteLLM pattern.
## Next steps
- **[AI Usage & Credit Billing](https://docs.fastsvelte.dev/features/ai-billing/index.md)** — how calls are metered, the monthly allotment, credit packs, and usage reporting.
- **[Stripe Integration](https://docs.fastsvelte.dev/features/billing/index.md)** — the subscription billing the AI allotment and credit purchases build on.
# AI Usage & Credit Billing
Every AI call is metered and billed against the **organization** (this kit is B2B multi-tenant; the org is the billing unit, which degrades naturally to "per user" for single-member orgs). Billing layers onto the existing [Stripe subscription](https://docs.fastsvelte.dev/features/billing/index.md) model — there is no parallel per-user credit system.
A call's tokens are charged in a fixed order:
1. **Monthly allotment** — the plan's included token budget, resets each billing period.
1. **Credit-pack balance** — purchased, non-expiring tokens.
1. **Overage** — usage beyond both. **Blocked by default** (see [Overage settings](#overage-settings)).
## How a call is billed
`backend/app/service/ai_usage_billing_service.py` owns this. Two entry points:
- **`has_ai_capacity(org_id, estimated_tokens)`** — a pre-flight check the copilot routes call *before* streaming, so an org that's out of capacity gets a clean `QuotaExceeded` instead of a half-streamed answer. It estimates from input length (~4 chars/token).
- **`stream_and_record(...)`** / **`record_llm_usage(...)`** — after the call, the actual `TokenUsage` is split across allotment → credits → overage, the consumed buckets are debited, the USD cost is computed (see [Model pricing](#model-pricing)), and a row is written to `llm_usage_log` tagged with the `bucket` it drew from.
For streaming calls, `stream_and_record` forwards text chunks to the client and bills the final `TokenUsage` once the stream ends — identical accounting to a non-streamed call.
## The monthly allotment
The allotment is just one feature in the generic [Plans & Usage](https://docs.fastsvelte.dev/features/plans-and-usage/index.md) system — the `token_limit` `FeatureKey`, metered per billing period and resetting each period. Set each plan's token budget alongside its other limits; it isn't a separate AI-only mechanism. An org with no `token_limit` has a zero allotment and must rely on credit packs.
## Credit packs
Packs are configured in **settings** — `settings.credit_packs`, with a default in `backend/app/config/settings.py` and overridable via the `FS_CREDIT_PACKS` env var (JSON). The defaults:
| Pack | Tokens | Price |
| ---------- | ---------- | ----- |
| `pack_5m` | 5,000,000 | $39 |
| `pack_10m` | 10,000,000 | $69 |
| `pack_25m` | 25,000,000 | $149 |
Change tokens or pricing in settings — no code edit, and **no Stripe products to create** (checkout builds the price inline via Stripe `price_data`). Purchased tokens are **non-expiring** and live in `organization_credit_balance`, separate from the per-period allotment.
**Purchase flow** (`backend/app/api/route/credit_pack_route.py`, all `ORG_ADMIN`):
| Endpoint | Purpose |
| ------------------------------------- | ------------------------------------------------------------------------------------------ |
| `GET /billing/credit-packs` | List available packs |
| `POST /billing/credit-packs/checkout` | `{ pack_id, return_base_url }` → `{ url }` — redirect the user to this Stripe Checkout URL |
The org must already have a Stripe customer (complete subscription billing setup first). Fulfillment is handled on the Stripe **`checkout.session.completed`** webhook (`backend/app/api/route/stripe_webhook_route.py`): `CreditPackService.fulfill_purchase` checks `metadata.type == "credit_pack"`, credits the balance, and appends an `organization_credit_transaction` audit row.
## Model pricing
USD cost per call is computed from the `model_price` table (seeded by migrations `006_model_price.sql` + `008_more_model_prices.sql`), which ships current OpenAI GPT-4.1 and GPT-5 series pricing (standard tier, per 1M tokens). A sample:
| Provider | Model | Input / 1M | Output / 1M |
| -------- | ---------------------- | ---------- | ----------- |
| openai | gpt-5-mini *(default)* | $0.25 | $2.00 |
| openai | gpt-5 | $1.25 | $10.00 |
| openai | gpt-5-nano | $0.05 | $0.40 |
| openai | gpt-4.1-mini | $0.40 | $1.60 |
| openai | gpt-4o-mini | $0.15 | $0.60 |
…plus `gpt-5.1`, `gpt-5.2`, `gpt-4.1`, `gpt-4o`, and the `-pro` variants. `LlmPricingService.compute_cost_usd` looks up the price (cached) and multiplies by input/output tokens. **If you point the copilot at a model that isn't in this table, calls will fail** — add a row (or a migration) for any model you enable.
## Usage reporting
`backend/app/api/route/usage_route.py` exposes:
| Endpoint | Role | Returns |
| ---------------------- | ---------------------------------------------- | ------------------------------------------------------------------------ |
| `GET /usage/summary` | `MEMBER` | used / limit / credit tokens for the period, plus the caller's own usage |
| `GET /usage/history` | `MEMBER` (own rows) · `ORG_ADMIN`+ (whole org) | paginated per-call log (`limit`, `offset`) |
| `GET /usage/top-users` | `ORG_ADMIN` | top users by tokens this period |
| `GET /usage/fleet` | `SYSTEM_ADMIN` | fleet-wide rollup over `days` — backs the admin AI-usage page |
The user-facing usage page reads `/summary` + `/history`; the system-admin AI-usage page reads `/fleet`.
## Overage settings
Overage is gated by two settings, **both `false` by default**, so usage beyond allotment + credits is blocked (a hard cap) rather than silently charged:
- **`overage_enabled`** (org setting) — an org opts into overage.
- **`ai_overage_enabled`** (system setting, `SYSTEM_ADMIN`) — an operator-level kill switch; an org's `overage_enabled` only takes effect if this is also on.
Both use the existing generic setting mechanism (org settings via `/organization/{id}/{key}`, system settings via `/system/{key}`). Actually **charging** for overage (Stripe metered billing / invoice items) is intentionally not implemented yet — leave overage off until you wire it up.
## Schema
Migration `007_ai_credit_billing.sql` adds:
- `llm_usage_log` — append-only per-request log (tokens, cost, `bucket` = `allotment` | `credit` | `overage`).
- `organization_credit_balance` — running non-expiring credit-pack balance.
- `organization_credit_transaction` — audit log of credit purchases/debits.
## Next steps
- **[AI Integration](https://docs.fastsvelte.dev/features/ai/index.md)** — the LLM client, the sample copilot, and streaming.
- **[Stripe Integration](https://docs.fastsvelte.dev/features/billing/index.md)** — the subscription billing this builds on.
# B2B Mode
[FastSvelte](https://fastsvelte.dev) supports two operational modes: **B2C** (Business-to-Consumer) and **B2B** (Business-to-Business). This guide explains how B2B mode works.
## What is B2B Mode?
In B2B mode, FastSvelte operates as a closed registration system where:
- Public signup is disabled
- Users can only join via invitation
- Organizations are managed by system administrators
- Each organization has its own admins who manage members
## Initial Setup
When you run `uv run init.py` and select **B2B mode**, the script creates:
1. A system administrator account (sys_admin)
1. Sets `FS_MODE=b2b` in your environment
## The B2B Flow
### 1. System Administrator Login
After running `init.py`, log in with the sys_admin credentials created during setup.
### 2. Create an Organization
1. Navigate to **System > Organizations** in the sidebar
1. Click **Create Organization**
1. Fill in the organization details:
1. **Organization Name**: The company/organization name
1. **Admin Email**: Email address for the organization administrator
1. **Admin Full Name**: Full name for billing purposes
1. Click **Create Organization**
**What happens next:**
- A new organization is created in the database
- A Stripe customer is automatically created for billing
- An invitation email is sent to the admin email address
- The organization admin can accept the invitation to create their account
### 3. Organization Admin Accepts Invitation
The organization admin receives an invitation email with a unique link. When they click it:
1. They're directed to the invitation acceptance page
1. They set their password and complete their profile
1. Their account is created with the `org_admin` role
1. They can now log in and access their organization
Email Service in Development
In development mode, the email service defaults to `stub` (no actual emails sent). To get invitation links, check your backend terminal for `[STUB EMAIL]` blocks containing the invitation URL. Copy and paste this URL in your browser to accept the invitation.
### 4. Organization Admin Manages Members
Once logged in, the org_admin can:
- **View Members**: See all users in their organization
- **Invite Users**: Send invitations to new members
- **Change Roles**: Update member roles (readonly, member, org_admin)
- **Remove Members**: Remove users from the organization
Access these features via **Organization > Users** in the sidebar.
### 5. Member Invitation Flow
When an org_admin invites a new member:
1. Navigate to **Organization > Invitations**
1. Create a new invitation with email and role
1. The invited user receives an email with an invitation link
1. They accept the invitation and create their account
1. They automatically join the organization with the assigned role
Finding Invitation Links in Development
Since the email service defaults to `stub` in development, check your backend logs for `[STUB EMAIL]` blocks that contain the invitation URL.
## User Roles in B2B Mode
| Role | Description | Permissions |
| ----------- | -------------------------- | ---------------------------------------------------- |
| `sys_admin` | System Administrator | Full system access, can manage all organizations |
| `org_admin` | Organization Administrator | Can manage their organization's members and settings |
| `member` | Regular Member | Can use application features |
| `readonly` | Read-only User | View-only access |
## What's Disabled in B2B Mode
When running in B2B mode:
- ❌ Public signup page (`/signup`) returns 404
- ❌ OAuth signup creates accounts only for existing invited users
- ❌ Individual billing is hidden (billing managed at organization level)
- ✅ Only invitation-based registration is allowed
## Development vs Production
The flow is the same in both development and production environments. The key differences:
- **Development**: Uses local email preview (no actual emails sent by default)
- **Production**: Sends real invitation emails via your configured email service
## Configuration
B2B mode is controlled by the backend `FS_MODE` environment variable:
```bash
# Backend (.env)
FS_MODE=b2b
```
The frontend doesn't need a mode variable — it reads the mode from the backend's `/config` endpoint at runtime. Update `FS_MODE` and restart the backend.
## Common Workflows
### Adding a New Company
1. sys_admin creates organization
1. Organization admin accepts invitation
1. org_admin invites team members
1. Members accept invitations and join
### Managing Organization Members
1. org_admin views members at `/organization/users`
1. Change member roles with the "Change Role" button
1. Remove members with the "Remove" button
1. Cannot remove yourself or the last org_admin
### Suspending an Organization
1. sys_admin navigates to organization details
1. Clicks "Suspend Organization"
1. All users in the organization are blocked from logging in
1. Data is preserved and can be reactivated later
# Database
FastSvelte uses **PostgreSQL** with a multi-tenant schema and **Sqitch** for migrations. Repositories use raw SQL (no ORM), so you always see exactly what runs.
## Multi-tenant schema
All business data is scoped to an **organization** (the tenant boundary). Core entities:
- `user` — individual accounts
- `organization` — the tenant; all business data belongs to one
- `role` — `readonly`, `member`, `org_admin`, `sys_admin` (see [Authentication](https://docs.fastsvelte.dev/features/authentication/index.md))
- `session` — server-side sessions
- `plan` / `organization_plan` — subscription tiers and the org's current plan
This supports both individual users and teams without changing the data model. See [Multi-Tenancy (B2B)](https://docs.fastsvelte.dev/features/multi-tenancy/index.md) for the organization/invitation model.
## Connection config
Connection settings come from `backend/.env` (`FS_DB_URL`, `FS_DB_SCHEMA`) via `backend/app/config/settings.py`, and are injected through the DI container's `db_config` into every repository — so repositories never construct their own connections.
## Migrations with Sqitch
Migrations are plain SQL files in `backend/db/`, versioned with Sqitch and reviewed alongside code in pull requests.
Create a migration:
```bash
cd backend/db
./sqitch.sh add add_feature -n "Add feature table"
```
This creates three files:
- `deploy/add_feature.sql` — how to apply the change
- `revert/add_feature.sql` — how to undo it
- `verify/add_feature.sql` — how to verify it worked
Deploy:
```bash
./sqitch.sh dev deploy
```
The `sqitch.sh` wrapper runs Sqitch via the official Docker image (no local Sqitch install needed) and adds FastSvelte-specific safety: per-environment database URLs (dev/beta/gamma/prod/test), a check that every migration is wrapped in `BEGIN;`/`COMMIT;`, revert protection (requires `--to`), and `.env` loading per environment.
### Why Sqitch and raw SQL?
Sqitch is language-agnostic and works with plain SQL — no ORM lock-in. You review and optimize the exact SQL that runs, and can still adopt SQLAlchemy or another tool later if your project needs it. See [Architecture](https://docs.fastsvelte.dev/reference/architecture/index.md) for the repository-layer reasoning.
# Transactional Email
FastSvelte sends transactional email — verification, password reset, and organization invitations — through a pluggable provider. Pick one of **Resend**, **SendGrid**, or **Azure Communication Services**, or use the **stub** provider in development.
## Choosing a Provider
Set `FS_EMAIL_PROVIDER` in `backend/.env` to one of: `resend`, `sendgrid`, `azure`, or `stub`.
For the providers you don't use, clean up all three places:
1. **`backend/pyproject.toml`** — delete the two package lines you don't need, then run `uv sync`:
```toml
"azure-communication-email>=1.1.0", # FS_EMAIL_PROVIDER=azure
"resend[async]>=2.30.1", # FS_EMAIL_PROVIDER=resend
"sendgrid>=6.12.5", # FS_EMAIL_PROVIDER=sendgrid
```
```bash
cd backend && uv sync
```
1. **`backend/.env`** — remove the env vars for the unused providers.
1. **`backend/app/config/settings.py`** — remove the corresponding settings fields (e.g. `resend_api_key`, `resend_sender_address`, `resend_sender_name`).
## Resend
A modern email API with a generous free tier — recommended for most new projects.
1. Sign up at [resend.com](https://resend.com) and create an API key at [resend.com/api-keys](https://resend.com/api-keys).
1. Add and verify your sending domain at [resend.com/domains](https://resend.com/domains).
1. Configure `backend/.env`:
```bash
FS_EMAIL_PROVIDER="resend"
FS_RESEND_API_KEY="re_your-resend-api-key-here"
FS_RESEND_SENDER_ADDRESS="noreply@yourdomain.com"
FS_RESEND_SENDER_NAME="Your App Name"
```
## SendGrid
```bash
FS_EMAIL_PROVIDER="sendgrid"
FS_SENDGRID_API_KEY="SG.your-sendgrid-api-key-here"
FS_SENDGRID_SENDER_ADDRESS="noreply@yourdomain.com"
FS_SENDGRID_SENDER_NAME="Your App Name"
```
Verify your sender email/domain in the SendGrid dashboard first.
## Azure Communication Services
For Azure-based deployments:
```bash
FS_EMAIL_PROVIDER="azure"
FS_AZURE_EMAIL_CONNECTION_STRING="endpoint=https://..."
FS_AZURE_EMAIL_SENDER_ADDRESS="noreply@yourdomain.com"
```
## Development (Stub)
For local development without sending real email:
```bash
FS_EMAIL_PROVIDER="stub"
```
Emails are logged to the backend console instead of sent — look for `[STUB EMAIL]` blocks containing verification and invitation links.
## Testing
```bash
curl -X POST http://localhost:8000/password/forgot \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com"}'
```
## Troubleshooting
- **Not sending** — verify the API key, confirm the sender domain/address is verified with the provider, and check the backend logs.
- **Production** — set up SPF/DKIM for your sending domain.
# Landing Page Template
FastSvelte includes a standalone, conversion-focused **landing site** in `landing/` — a separate SvelteKit app from the dashboard (`frontend/`), so your marketing pages deploy independently of the app. It's themed (light/dark) and SEO-optimized out of the box. The steps below take you from the template to your own landing page.
## 1. Find it
The landing site lives in `landing/`. The home page (`landing/src/routes/+page.svelte`) is assembled from section components in `landing/src/lib/components/landing/`. Edit a section to change its content; reorder or remove sections in `+page.svelte`.
## 2. Set your branding
- `landing/src/lib/config.ts` — `appName` and `siteUrl`.
- `Logo.svelte` — swap in your logo (light/dark variants).
- `landing/.env` — browser-exposed vars use the `PUBLIC_*` prefix (e.g. `PUBLIC_APP_NAME`, `PUBLIC_API_BASE_URL`). Run `npx svelte-kit sync` after changing them.
## 3. Arrange your sections
`+page.svelte` composes the page from these sections, in order:
```text
```
Reorder, drop, or duplicate any line to change the page. `Showcase` ships commented out — uncomment it to enable. `Newsletter` is included as a component but not placed on the page; add `` (and its import) wherever you want it.
## 4. Write the copy
Each section is its own component in `landing/src/lib/components/landing/`. Edit the one you want:
- **Topbar** — navigation links and the primary CTA.
- **Hero** — headline, subheading, and the main call to action.
- **Features** — the grid of product capabilities.
- **Showcase** — a "copilot in action" demo panel (off by default).
- **Testimonial** — social proof and customer quotes.
- **Pricing** — pricing tiers (this was previously named `BundleOffer`).
- **FAQ** — common questions and answers.
- **CTA** — the closing call to action.
- **Newsletter** — an email-capture signup (UI only — wire the `