Everything runs on your machine: Postgres and S3-compatible storage in Docker, the apps with
pnpm dev. Nothing here talks to production. Read Monorepo overview first if
you do not know the layout yet.
Prerequisites
| Requirement | Why |
|---|---|
Node >=22 | engines in the root package.json; the production image uses Node 22 too |
pnpm 11.24.0 | pinned in packageManager; enable it with corepack |
| Docker with Compose | Postgres, RustFS (S3) and Mailpit come from infra/docker-compose.yml |
| Chrome or Chromium (optional) | only for pnpm lighthouse and the Playwright e2e tests |
corepack enable
corepack prepare pnpm@11.24.0 --activate
node --version # v22 or newer1. Install
git clone <the platform repository> kwisple
cd kwisple
pnpm installInstall scripts run only for the packages listed under allowBuilds in
pnpm-workspace.yaml (@biomejs/biome, @resvg/resvg-js, sharp, esbuild). If sharp
or resvg fail to install, that is the place to look.
2. Infrastructure
pnpm infra:up # docker compose -f infra/docker-compose.yml up -d
pnpm infra:down # stop it againThe compose project is called kwisple and starts three containers:
| Service | Image | Host port(s) | Notes |
|---|---|---|---|
postgres | postgres:17-alpine | 5433 → 5432 | database rva_studio, data in infra/data/postgres |
storage | rustfs/rustfs:latest | 9100 → 9000 (S3 API), 9101 → 9001 (console) | MinIO-compatible API; data in infra/data/storage. Host ports 9100/9101 because 9000 is often taken locally (Homebrew PHP-FPM). |
mail | axllent/mailpit:v1.27 | 1125 → 1025 (SMTP), 8125 → 8025 (inbox) | catches every mail; nothing leaves your machine. Inbox on http://localhost:8125. |
Create the assets bucket once (public read + CORS for the Studio canvas):
pnpm infra:init # pnpm --filter @kwisple/storage init-bucket3. Environment file
cp .env.example .envEvery app reads the same .env at the repository root (the Next apps through dotenv, the
worker and agent through --env-file-if-exists=../../.env). turbo.json lists the variables it
passes through to tasks (globalPassThroughEnv); a new variable must be added there too.
Fill in the values yourself; never commit .env. The variables, by group:
Database and auth
| Variable | Meaning |
|---|---|
DATABASE_URL | Postgres connection string. The local default points at the Docker database on port 5433. |
BETTER_AUTH_SECRET | Signs sessions. >= 32 characters (openssl rand -base64 32); production refuses to start otherwise, development warns. |
BETTER_AUTH_URL | The dashboard's own URL (http://localhost:4100 locally). |
URLs
| Variable | Meaning |
|---|---|
DASHBOARD_URL | Public dashboard URL (also baked into the web app at build time for CSP). |
WEB_URL | Public URL of apps/web; the dashboard calls <WEB_URL>/api/revalidate. |
PLATFORM_DOMAIN | Sites get <slug>.<PLATFORM_DOMAIN> before they have a domain (locally sites.localhost:4101). |
DEV_SITE_SLUG | Development only: the site apps/web serves on a bare localhost host instead of a 404. |
Object storage
| Variable | Meaning |
|---|---|
STORAGE_ENDPOINT | S3 endpoint (locally the RustFS API on port 9100). |
STORAGE_REGION | Region string (auto for RustFS/R2). |
STORAGE_BUCKET | Bucket name. |
STORAGE_ACCESS_KEY_ID, STORAGE_SECRET_ACCESS_KEY | Credentials (locally the RustFS keys from infra/docker-compose.yml). |
STORAGE_PUBLIC_URL | Public origin of stored objects; next/image only allows this host. Read at build time. |
Secrets shared by dashboard and web
| Variable | Meaning |
|---|---|
REVALIDATE_SECRET | Bearer token the dashboard uses on apps/web /api/revalidate. Identical in both apps, >= 32 characters. |
PREVIEW_SECRET | Signs live-preview tokens (dashboard signs, web verifies). Empty = derived from BETTER_AUTH_SECRET (dev only; production logs a warning). |
Assistant and agent
| Variable | Meaning |
|---|---|
ASSISTANT_URL | Where the dashboard sends kwisple.assistant/v1 tasks. Empty = assistant features hidden. Locally http://localhost:4110. |
ASSISTANT_TOKEN | Shared bearer token between dashboard and agent. |
ANTHROPIC_API_KEY | Read only by apps/agent. Never by the dashboard or sites. |
AGENT_MODEL | Model id the agent uses (default claude-opus-5-5). |
AGENT_PORT | Agent port (default 4110). |
AGENT_MCP_URL | Where the agent reaches the dashboard's MCP endpoint for the chat (default <DASHBOARD_URL>/api/mcp). |
Background jobs
| Variable | Meaning |
|---|---|
JOBS_INLINE | 1 = the dashboard server runs the job handlers itself; 0 = a separate worker does. Empty = on outside production, off in production. |
Client IP, proxies and auth hardening
| Variable | Meaning |
|---|---|
TRUST_PROXY | 1 = trust forwarded headers (only behind a proxy you control). |
TRUSTED_IP_HEADER | Which header carries the client IP (x-forwarded-for, cf-connecting-ip, x-real-ip). |
TRUSTED_PROXY_HOPS | Extra proxies to skip in an x-forwarded-for chain. |
TRUSTED_PROXIES | Better Auth: proxy IPs/CIDRs for x-forwarded-for. |
ALLOW_SIGNUP | Open self-service sign-up. Empty = on in development, off in production. The first account of an empty database is always allowed. |
AUTH_RATE_LIMIT_STORAGE | memory (default) or database (needs Better Auth's rate_limit table). |
AUTH_RATE_LIMIT | 1 enables Better Auth's rate limits in development. |
OUTBOUND_ALLOW_PRIVATE | Webhooks to private/loopback targets: empty = allowed in development, blocked in production; 0/1 force. |
Optional integrations (empty = feature hidden or manual mode)
| Variable | Meaning |
|---|---|
CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN | Platform-managed Cloudflare Pages hosting for coded sites. |
HOSTING_SECRET_KEY | Encrypts stored deploy hooks and webhook secrets (>= 32 random bytes; falls back to a key derived from BETTER_AUTH_SECRET). |
HOSTING_FAKE | Test hosting provider: on outside production; 1/0 force. |
META_APP_ID, META_APP_SECRET, META_GRAPH_VERSION | Automatic posting to Facebook/Instagram. |
LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET, LINKEDIN_VERSION | Automatic posting to LinkedIn pages. |
SOCIAL_TOKEN_KEY | Encrypts social tokens at rest (falls back to BETTER_AUTH_SECRET). |
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, MAIL_FROM | Outgoing mail. Empty SMTP_HOST = no mail (invitations show a link to pass on). Locally point at Mailpit: host 127.0.0.1, port 1125. |
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET | Stripe Billing for agency subscriptions; empty = manual mode. |
STRIPE_CONNECT_WEBHOOK_SECRET, STRIPE_CONNECT_FEE_PERCENT | Stripe Connect for client packages. |
Database tuning and debugging
| Variable | Meaning |
|---|---|
DATABASE_POOL_MAX | Connections per process (default 10). |
DATABASE_PREPARE | false behind a transaction-mode pooler (PgBouncer). |
DB_DEBUG | 1 logs every SQL query (count queries per page with pnpm preview). |
4. Database schema and seed
pnpm db:migrate # drizzle-kit migrate: applies packages/db/drizzle/*.sql in order
pnpm db:seed # pnpm --filter @kwisple/content seedpnpm db:migrate is what production runs. pnpm db:push (drizzle-kit push) diffs the
Drizzle schema straight into the database without writing a migration file; use it only for
throw-away experiments on a local database and never in production, because the next
pnpm db:generate would then produce a migration that production still needs but your
database already has. The normal flow for a schema change is in
Adding things.
pnpm db:seed runs packages/content/scripts/seed.ts: one agency ("Mijn bureau"), one demo
client ("Demo Bakkerij") with a brand kit and a hosted site on the standard schema (home, over
ons, contact). It seeds no users.
Other database scripts:
| Script | What |
|---|---|
pnpm db:generate | drizzle-kit generate: writes a migration from schema changes |
pnpm db:studio | drizzle-kit studio: browse the database in the browser |
5. Run the apps
pnpm devpnpm dev runs turbo run dev for @kwisple/dashboard (4100), @kwisple/web (4101) and
@kwisple/agent (4110) side by side in Turborepo's TUI. Open http://localhost:4100.
| Command | Starts |
|---|---|
pnpm dev | dashboard, web app and agent |
pnpm --filter @kwisple/dashboard dev | only the dashboard |
pnpm --filter @kwisple/web dev | only the web app |
pnpm agent | only the agent (tsx watch src/server.ts, needs ANTHROPIC_API_KEY and ASSISTANT_TOKEN) |
node tools/assistant-stub/server.mjs | a rule-based assistant on 4110 (no model, no key) for testing the flow |
pnpm --filter @kwisple/dashboard worker | the pg-boss worker as its own process |
pnpm starter | the Next.js client-site starter on 4102 |
pnpm starter:astro | the Astro starter on 4104 |
pnpm site | the product's own website on 4105 |
pnpm build && pnpm preview | production builds, then next start for dashboard and web |
The worker
In development you normally do not start the worker: JOBS_INLINE is empty, so
inlineWorkersEnabled() (apps/dashboard/src/worker/inline.ts) returns true outside
production and the dashboard server registers every job handler in its own process at boot
(src/instrumentation.ts). Start the standalone worker when you want to see job output
separately or test the production shape:
JOBS_INLINE=0 pnpm --filter @kwisple/dashboard dev # in one terminal
pnpm --filter @kwisple/dashboard worker # in anotherThe worker process sets JOBS_INLINE=0 for itself so it never starts a second lazy set of
handlers. See Jobs and the worker.
Production mode locally
Development mode compiles each page on first visit (1.5–3.5 s) and is not representative. To click through the product at real speed:
pnpm build
pnpm previewpnpm preview is turbo run start for the dashboard and the web app on the same ports.
DB_DEBUG=1 pnpm preview logs every query.
6. Log in
Register through the dashboard: http://localhost:4100/register. The first account of an
empty database becomes platform admin and owner of the first agency (the seeded "Mijn
bureau"); ALLOW_SIGNUP is on in development, so later accounts can register too.
For testing with every kind of role there are two seed scripts in apps/dashboard/scripts.
Each creates accounts with one fixed password that is written in the script itself; read it
there, it is not repeated here.
| Script | Creates |
|---|---|
pnpm --filter @kwisple/dashboard exec tsx scripts/seed-test-accounts.ts | five accounts on the seeded "Demo Bakkerij" client: an agency admin and member, a client owner, admin and member (addresses end in @test.localhost). Run pnpm db:seed first. |
pnpm --filter @kwisple/dashboard exec tsx --env-file=../../.env scripts/seed-personas.ts | the persona environment: agency "Persona Bureau" with clients of different sizes, plus agencies with a stopped and an almost-ended subscription. |
Both refuse to run with NODE_ENV=production and are safe to re-run.
Mail: with SMTP_HOST=127.0.0.1 and SMTP_PORT=1125 every invitation, approval and form
notification lands in Mailpit on http://localhost:8125.
7. Working on a client site locally
The Next starter (pnpm starter) reads content from your local platform through the delivery
API. Create a site of mode headless in the dashboard, make a token under Website →
Instellingen → Ontwikkeling and put it in the starter's .env; kwisple dev pushes
cms.config.ts while you work. The details are on the site side of this documentation
(Quick start, Environment variables and tokens).
Hosted (legacy) sites render on http://<slug>.sites.localhost:4101/ (Chrome resolves
*.localhost to loopback) or on http://localhost:4101/ when DEV_SITE_SLUG names the site.
Common local pitfalls
| Symptom | Cause and fix |
|---|---|
useT throws, or a new message key is missing although it is in nl.json | The catalogue types are generated from nl.json at build; after adding messages restart the dev server (and make sure the key exists in fr.json and en.json, or pnpm check:i18n fails). |
| A page's client component says its namespace was not provided | Add the namespace to the nearest layout's <ClientMessages namespaces={[...]}>. |
| Port 4100/4101 already in use | Another dev server is running; pnpm dev does not pick a free port. Stop it or run one app with pnpm --filter … dev. |
| Port 9000 conflicts | Not an issue: the compose file maps RustFS to 9100/9101 on purpose. Check STORAGE_ENDPOINT uses 9100. |
| Uploads fail with a bucket error | Run pnpm infra:init once after the first pnpm infra:up. |
| Jobs never run (renders stay "bezig", thumbnails missing) | JOBS_INLINE=0 is set but no worker is running. Unset it, or start pnpm --filter @kwisple/dashboard worker. |
| A job added in this session is not picked up by a long-running dev server | Inline workers register handlers at boot; restart the dev server (or, for site.deploy, the code calls ensureInlineJob itself). |
| Publishing says the web app could not be revalidated | apps/web is not running or WEB_URL/REVALIDATE_SECRET differ between the apps. |
| The webhook to a local starter is refused | In development private targets are allowed unless OUTBOUND_ALLOW_PRIVATE=0; check that flag. |
drizzle-kit complains about DATABASE_URL | packages/db/drizzle.config.ts reads ../../.env; run the db:* scripts from the repository root. |
| Production build refuses to start | BETTER_AUTH_SECRET or REVALIDATE_SECRET shorter than 32 characters (@kwisple/core/secrets). |
| Database-backed tests are skipped | They check select 1 at start and skip when Postgres is unreachable; run pnpm infra:up. |
Day-to-day checks
Before you commit, run what CI runs (details in Testing and checks):
pnpm lint
pnpm check:i18n
pnpm check:actions
pnpm typecheck
pnpm test