The platform is one repository: a pnpm workspace driven by Turborepo. Three apps ship to
production, a dozen packages hold the shared code, and the starters and the product's own
website live next to them so they are built and type-checked with the same toolchain. This
page is the map. The rules you follow while working in it are in Conventions;
docs/ARCHITECTURE.md in the repository is the source of truth for module boundaries and the
tenancy model, and this page follows it.
Toolchain
| Tool | Version / notes |
|---|---|
| Node | >=22 (engines in the root package.json) |
| pnpm | 11.24.0, pinned in packageManager (use corepack) |
| Turborepo | 2.11.9, config in turbo.json |
| TypeScript | 5.9.3, strict, one tsconfig.base.json at the root |
| Next.js | 16.4.2 (App Router, proxy.ts, async request APIs) |
| React | 19.3.1 |
| Tailwind | v4 (@tailwindcss/postcss in the Next apps, @tailwindcss/vite in Astro) |
| Drizzle ORM | 0.45.4 with drizzle-kit 0.31.11 and the postgres driver |
| Better Auth | 1.7.12 (organization plugin = workspace) |
| Zod | 4.6.7 |
| pg-boss | 12.38.0 |
| Biome | 2.5.15 (format + lint) |
| Vitest | 5.0.5 (plus node:test for a few older dashboard tests) |
| Playwright | ^1.64.1 (end-to-end tests in e2e/) |
The workspace globs are in pnpm-workspace.yaml:
packages:
- "apps/*"
- "packages/*"
- "starters/*"
- "sites/*"Only @biomejs/biome, @resvg/resvg-js, sharp and esbuild are allowed to run install
scripts (allowBuilds).
Top-level layout
apps/
dashboard/ The app agencies and clients log into. Auth, agencies, workspaces, CMS editor,
brand, social, Studio, toolkit, MCP endpoint, delivery of the handleiding. :4100
web/ Multi-tenant public web app: delivery API /api/v1, legacy hosted site
renderer, statistics collection, forms. :4101
agent/ Kwisple Agent: answers kwisple.assistant/v1 tasks and the assistant chat with
a language model. The only process that talks to a model. :4110
packages/ Shared code (below).
starters/
next/ Starter for a client site repository (Next.js 16 + @kwisple/sdk). :4102
astro/ Starter for a client site repository (Astro + @kwisple/sdk/astro). :4104
sites/
kwisple/ The product's own website, made with the product (Astro). :4105
docs/ Internal design documents, the handleiding (served on /hulp) and this
developer documentation.
tools/ Repository scripts: i18n and action checks, release dry run, Lighthouse
guard, backup drill, go-live scripts, the rule-based assistant stub.
infra/ docker-compose for local Postgres + S3 storage + Mailpit, the production
compose file, the Caddyfile and the backup image.
e2e/ Playwright tests of the core flows against a running dashboard.
Dockerfile Multi-target production image (dashboard, web, worker, agent, migrate).
.github/workflows/ci.yml The CI pipeline.Apps
apps/dashboard (@kwisple/dashboard, port 4100)
The dashboard is the product. It owns authentication (Better Auth), the three-level tenancy
(platform → agency → workspace), every editor (CMS entries, brand kit, social posts, Studio
canvas), the toolkit, billing, requests, notifications, the Websitecheck, the MCP endpoint for
agents (/api/mcp), the CLI endpoints (/api/cli/*), the apps API (/api/apps/v1) and the
handleiding on /hulp.
| Script | What |
|---|---|
pnpm --filter @kwisple/dashboard dev | next dev -p 4100 |
pnpm --filter @kwisple/dashboard build / start | production build / next start -p 4100 |
pnpm --filter @kwisple/dashboard worker | the pg-boss worker (tsx src/worker/index.ts) |
pnpm --filter @kwisple/dashboard test | tsx --test test/*.test.ts && vitest run |
pnpm --filter @kwisple/dashboard typecheck | tsc -p tsconfig.json |
Important folders under apps/dashboard/src:
| Folder | Holds |
|---|---|
app/(app)/w/[workspace]/… | client workspace screens (overzicht, website, brand, media, social, studio, toolkit, statistieken, verzoeken, apps, instellingen, rapport) |
app/(app)/bureau/[agency]/… | the agency environment (klanten, team, white-label, abonnement, eigen-projecten, verzoeken, kalender, statistieken, website, instellingen) |
app/(app)/admin/… | Platformbeheer (bureaus, abonnementen, diensten, statistieken, systeem, meldingen, design, website) |
app/(app)/hulp/[[...slug]] | the handleiding |
app/api/… | route handlers: auth, mcp, oauth, cli, apps/v1, assistant/chat, billing, social, toolkit, upload, assets, export, health |
lib/session.ts, lib/access.ts, lib/permissions.ts, lib/access-policy.ts | who may do what (Tenancy and permissions) |
lib/actions/* | server actions, one file per module |
lib/services/* | the service layer the actions, route handlers, agent tools and worker share |
lib/agent/* | MCP tokens, OAuth, workspace access and the tool definitions |
worker/ | index.ts (process entry), start.ts (handlers + schedule), inline.ts, handlers/* |
i18n/ | translators, formatter, catalogues (Languages and design system) |
components/ | server and client components; components/ui holds dashboard-only primitives |
messages/ (next to src) | nl.json, fr.json, en.json |
apps/web (@kwisple/web, port 4101)
One deployment serves every client site that the platform renders itself and the delivery API
for custom-code sites. src/proxy.ts reads the Host header, maps it to a site key and
rewrites the request into the internal /-sites/[key]/[locale]/… tree (or /-preview/… for
live preview). Everything under /api is left alone:
| Route | Purpose |
|---|---|
/api/v1/* | the delivery API (bearer token per site): site, entries, entry, tree, globals, brand, schema, preview/verify, redirects/match, not-found, forms, forms/[form]/slots, consent, collect, a/[file], agent, seed, staging/login |
/api/revalidate | the dashboard tells the web app which cache tags changed (REVALIDATE_SECRET) |
/api/preview, /api/preview/exit | live preview cookie for hosted sites |
/api/forms, /api/not-found, /api/agent | public endpoints of hosted sites (rate limited) |
/api/health | liveness: { ok: true } while the process serves requests (the dashboard's checks the database) |
/-sites/[key]/[locale]/… | legacy hosted site renderer: pages, sitemap, robots, icon, apple-icon, opengraph-image |
(platform)/ | what a visitor sees on the bare platform host |
src/blocks/* holds the renderers of the legacy hosted blocks (see
The content engine).
apps/agent (@kwisple/agent, port 4110)
A small node:http service (src/server.ts) that answers POST / tasks (social.captions,
social.ideas, website.review) and POST /chat (the assistant chat, NDJSON stream), with
GET /health. It depends on @kwisple/core and nothing else from the platform; the model sits
behind the Model interface in src/model.ts (ClaudeModel reads ANTHROPIC_API_KEY). It
runs with tsx (pnpm agent at the root).
Packages
Every package exports TypeScript source directly ("exports": { ".": "./src/index.ts" }); the
monorepo never builds them for internal use. Only @kwisple/cms, @kwisple/sdk and
create-kwisple-site are published to npm; they get a dist/ through
tools/build-package.mjs and swap their exports with publishConfig at publish time.
| Package | What it is | Depends on (workspace) | Notable exports |
|---|---|---|---|
@kwisple/core | Domain contracts: Zod schemas for brand, site settings, SEO, social, agencies, assistant protocol, consent, webhooks; ids, slugs, UTM, URL guard (safeFetch), secret checks, the product object. No React, no database. | none | product, newId/shortId/idPrefixes, webhookEvents, siteSettingsSchema, safeFetch, checkSecret; sub-paths ./brand, ./social, ./assistant, ./consent, ./net, ./secrets, ./web-security, ./utm, … |
@kwisple/db | Drizzle schema (src/schema/*), the postgres client, migrations in drizzle/, tenant helpers. | core | db, schema, forWorkspace, live, liveWorkspace, clientWorkspace, productionSite, trashTables, re-exported drizzle operators |
@kwisple/cms | Content model as code (Craft-style DSL): fields, entry types, sections, globals, apps, forms, staging, value validation and normalisation, schema diff, rich-text sanitiser. Zero server dependencies. ./standard = the built-in schema of hosted sites. | none (zod only) | defineSchema, defineSection, defineEntryType, defineGlobalSet, defineApp, f.*, diffSchemas, sanitize*, standardSchema, builderBlocks |
@kwisple/content | The server-side content engine: reads, editor state, drafts, publish, URIs, redirects and 404 log, globals, revisions, trash, API tokens, preview tokens, cache tags, the publish webhook, seeds, staging. | cms, core, db, storage | loadSiteCtx, findEntries, findEntry, publishEntry, saveProvisional, getSchema, pushSchema, siteCacheTags, sendPublishWebhook, authenticateToken; ./preview-token |
@kwisple/sdk | Typed client, Next.js and Astro helpers, consent components and the kwisple CLI for client site repositories. Never imports the database. | cms, create-kwisple-site | createClient, ./next, ./astro, ./app (connectApp), ./consent; bin kwisple |
@kwisple/ui | Shared UI primitives and the design tokens (styles.css) for the dashboard. | none (React peer) | Button, Card, Badge, Input, Switch, Segmented, PixelField, BrandMosaic, StatusBadge, cn, UiLocaleProvider |
@kwisple/brand | Brand kit → CSS variables and tokens, palettes, fonts, SVG handling, contrast and validation. | core | validateBrandKit, contrastRatio, WCAG, readableOn |
@kwisple/social | Social templates rendered with Satori → SVG → PNG (resvg), caption helpers, idea builders. | brand, core | renderSocialImage |
@kwisple/storage | S3-compatible client, object keys, upload preparation (magic-byte sniffing, SVG sanitising), image inspection and thumbnails (sharp). | core | objectKey, publicUrl, prepareUpload, inspectImage, makeThumbnail |
@kwisple/analytics | First-party cookieless statistics: collection, nightly rollup, benchmarks, dashboard queries, AI-agent detection, consent, the tracker script. | core, db | runNightly, runBenchmarks, reviewPeriod, detectAiAgent; ./agents, ./pure, ./tracker |
@kwisple/jobs | Background job definitions on pg-boss: jobSchemas, enqueue, work, schedule. | none (pg-boss, zod) | see Jobs and the worker |
create-kwisple-site | npm create kwisple-site: scaffolds a client site repo from a starter, with kits in kits/. | none | bin create-kwisple-site |
Dependency direction
Taken from docs/ARCHITECTURE.md §2; never the reverse.
core ← brand ← social
core ← db ← jobs
core, db ← analytics
cms, core, db, storage ← content
cms ← sdk (client site repos; never imports db)
core, db, brand, storage, social, ui, cms, content, analytics, jobs ← apps/dashboard, apps/web
core ← apps/agent (nothing else from the platform)Rules that follow from it:
- Apps never import from other apps.
apps/webandapps/dashboardtalk over HTTP (/api/revalidate) and through the database, never through imports. - The agent only imports
@kwisple/core(protocol types and schemas). It has no database, no storage and no knowledge of the dashboard's internals; it reaches platform data through the MCP endpoint with a short-lived chat token, with exactly the rights of the person. - Content reads and writes go through
@kwisple/content. Apps never querycms_*tables. - Every tenant-scoped query goes through
forWorkspace(db, workspaceId)from@kwisple/db. @kwisple/cmsand@kwisple/sdkare published packages used inside client repositories, so they stay free of server dependencies.
Starters and sites
| Workspace | Port | What |
|---|---|---|
@kwisple/starter-next (starters/next) | 4102 | Next.js 16 client site starter: cms.config.ts, src/views, src/builder, src/proxy.ts. pnpm starter at the root. |
@kwisple/starter-astro (starters/astro) | 4104 | Astro client site starter with @kwisple/sdk/astro. pnpm starter:astro. |
@kwisple/site-kwisple (sites/kwisple) | 4105 | The product's own website (Astro), with setup:platform and seed scripts. pnpm site. |
pnpm build at the root excludes the starters and the product site
(--filter=!@kwisple/starter-next --filter=!@kwisple/starter-astro --filter=!@kwisple/site-kwisple);
they are type-checked and tested like everything else.
Ports at a glance
| Port | Process |
|---|---|
| 4100 | apps/dashboard |
| 4101 | apps/web |
| 4102 | starters/next |
| 4104 | starters/astro |
| 4105 | sites/kwisple |
| 4110 | apps/agent (also the rule-based stub tools/assistant-stub/server.mjs) |
| 5433 | local Postgres (infra/docker-compose.yml, container port 5432) |
| 9100 / 9101 | local RustFS S3 API / console |
| 1125 / 8125 | local Mailpit SMTP / inbox |
Turborepo tasks
turbo.json defines build (depends on ^build, caches .next/** and dist/**), dev
(persistent, uncached), typecheck and test (both depend on ^build) and start. Root
scripts wrap them:
| Root script | Runs |
|---|---|
pnpm dev | turbo run dev for the dashboard, web and agent |
pnpm preview | turbo run start for the dashboard and web (after pnpm build) |
pnpm build | every workspace except the starters and the product site |
pnpm typecheck, pnpm test | across the workspace |
pnpm lint | biome check . |
Environment variables the tasks may read are listed in globalPassThroughEnv in turbo.json;
a new variable must be added there or Turborepo hides it from the task.
Request paths
A public site request (custom-code site)
visitor ──► client site (own repo, Next.js or Astro, own hosting)
│ @kwisple/sdk createClient(...).entryByPath(...)
▼
apps/web /api/v1/entry Authorization: Bearer <site token>
│ authenticate(req, "read") → authenticateToken (cms_api_token, cached 60 s)
│ cachedRead(...) → unstable_cache, tags cms:site:<id> (+ entry tags), 1 h
│ findEntry(ctx, …) → @kwisple/content → forWorkspace(db, ws) → cms_* tables
▼
JSON { entry } (private, max-age=60, stale-while-revalidate=600)When an editor publishes, the dashboard calls afterContentChange (lib/services/cms-publish.ts):
it POSTs the changed tags to apps/web /api/revalidate and, for a headless site, sends the
signed webhook to the site's /api/revalidate, which purges the SDK's kwisple:all tag. The
next visitor gets fresh content; nothing is rebuilt.
A public site request (legacy hosted site)
visitor ──► apps/web (Host: client.be or <slug>.<PLATFORM_DOMAIN>)
│ proxy.ts: host → site key, locale prefix, preview token check
│ rewrite → /-sites/[key]/[locale]/[[...slug]] (ISR, revalidate = false)
│ lib/site.ts getSite… (unstable_cache: host:*, site:<id>, brand:<ws>, agency:<id>)
│ @kwisple/content findEntry → RenderBlocks (apps/web/src/blocks)
▼
HTML, cached until a tag purge from the dashboardA dashboard request
browser ──► apps/dashboard /w/<workspace>/social
│ proxy.ts: session cookie present? else redirect to /login/<workspace>;
│ sets x-kwisple-workspace header for texts that name the agency
│ layout + page (Server Components)
│ requireWorkspace(slug) → session, resolveWorkspaceAccess
│ (platform admin / agency member / workspace member,
│ access policy → grants, "Bekijk als klant" cookie)
│ can(ctx, "social.view") → hide or redirect(NO_ACCESS)
│ services (lib/services/*) with ctx.wdb = forWorkspace(db, workspace.id)
▼
HTML; later interactions call server actions (lib/actions/*), which run
requireWorkspace + can() again, validate with Zod, write through services and
revalidatePath(...)Heavy work (renders, exports, mails, webhooks) is never done in these requests: the action
calls enqueue(...) and the worker picks it up (Jobs and the worker).
Where to read next
- Local setup: get all of this running on your machine.
- Conventions: the rules with the why.
- Tenancy and permissions, The content engine, Jobs and the worker, Languages and design system.
- Adding things: recipes, Testing and checks, Deploying the platform.