Kwisple
Browse the docs

The platform repository › Getting around

Monorepo overview

What lives where in the platform repository, which app or package depends on which, what runs on which port, and how a request travels through the system.

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

ToolVersion / notes
Node>=22 (engines in the root package.json)
pnpm11.24.0, pinned in packageManager (use corepack)
Turborepo2.11.9, config in turbo.json
TypeScript5.9.3, strict, one tsconfig.base.json at the root
Next.js16.4.2 (App Router, proxy.ts, async request APIs)
React19.3.1
Tailwindv4 (@tailwindcss/postcss in the Next apps, @tailwindcss/vite in Astro)
Drizzle ORM0.45.4 with drizzle-kit 0.31.11 and the postgres driver
Better Auth1.7.12 (organization plugin = workspace)
Zod4.6.7
pg-boss12.38.0
Biome2.5.15 (format + lint)
Vitest5.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.

ScriptWhat
pnpm --filter @kwisple/dashboard devnext dev -p 4100
pnpm --filter @kwisple/dashboard build / startproduction build / next start -p 4100
pnpm --filter @kwisple/dashboard workerthe pg-boss worker (tsx src/worker/index.ts)
pnpm --filter @kwisple/dashboard testtsx --test test/*.test.ts && vitest run
pnpm --filter @kwisple/dashboard typechecktsc -p tsconfig.json

Important folders under apps/dashboard/src:

FolderHolds
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.tswho 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:

RoutePurpose
/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/revalidatethe dashboard tells the web app which cache tags changed (REVALIDATE_SECRET)
/api/preview, /api/preview/exitlive preview cookie for hosted sites
/api/forms, /api/not-found, /api/agentpublic endpoints of hosted sites (rate limited)
/api/healthliveness: { 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.

PackageWhat it isDepends on (workspace)Notable exports
@kwisple/coreDomain 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.noneproduct, newId/shortId/idPrefixes, webhookEvents, siteSettingsSchema, safeFetch, checkSecret; sub-paths ./brand, ./social, ./assistant, ./consent, ./net, ./secrets, ./web-security, ./utm, …
@kwisple/dbDrizzle schema (src/schema/*), the postgres client, migrations in drizzle/, tenant helpers.coredb, schema, forWorkspace, live, liveWorkspace, clientWorkspace, productionSite, trashTables, re-exported drizzle operators
@kwisple/cmsContent 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/contentThe 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, storageloadSiteCtx, findEntries, findEntry, publishEntry, saveProvisional, getSchema, pushSchema, siteCacheTags, sendPublishWebhook, authenticateToken; ./preview-token
@kwisple/sdkTyped client, Next.js and Astro helpers, consent components and the kwisple CLI for client site repositories. Never imports the database.cms, create-kwisple-sitecreateClient, ./next, ./astro, ./app (connectApp), ./consent; bin kwisple
@kwisple/uiShared 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/brandBrand kit → CSS variables and tokens, palettes, fonts, SVG handling, contrast and validation.corevalidateBrandKit, contrastRatio, WCAG, readableOn
@kwisple/socialSocial templates rendered with Satori → SVG → PNG (resvg), caption helpers, idea builders.brand, corerenderSocialImage
@kwisple/storageS3-compatible client, object keys, upload preparation (magic-byte sniffing, SVG sanitising), image inspection and thumbnails (sharp).coreobjectKey, publicUrl, prepareUpload, inspectImage, makeThumbnail
@kwisple/analyticsFirst-party cookieless statistics: collection, nightly rollup, benchmarks, dashboard queries, AI-agent detection, consent, the tracker script.core, dbrunNightly, runBenchmarks, reviewPeriod, detectAiAgent; ./agents, ./pure, ./tracker
@kwisple/jobsBackground job definitions on pg-boss: jobSchemas, enqueue, work, schedule.none (pg-boss, zod)see Jobs and the worker
create-kwisple-sitenpm create kwisple-site: scaffolds a client site repo from a starter, with kits in kits/.nonebin 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/web and apps/dashboard talk 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 query cms_* tables.
  • Every tenant-scoped query goes through forWorkspace(db, workspaceId) from @kwisple/db.
  • @kwisple/cms and @kwisple/sdk are published packages used inside client repositories, so they stay free of server dependencies.

Starters and sites

WorkspacePortWhat
@kwisple/starter-next (starters/next)4102Next.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)4104Astro client site starter with @kwisple/sdk/astro. pnpm starter:astro.
@kwisple/site-kwisple (sites/kwisple)4105The 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

PortProcess
4100apps/dashboard
4101apps/web
4102starters/next
4104starters/astro
4105sites/kwisple
4110apps/agent (also the rule-based stub tools/assistant-stub/server.mjs)
5433local Postgres (infra/docker-compose.yml, container port 5432)
9100 / 9101local RustFS S3 API / console
1125 / 8125local 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 scriptRuns
pnpm devturbo run dev for the dashboard, web and agent
pnpm previewturbo run start for the dashboard and web (after pnpm build)
pnpm buildevery workspace except the starters and the product site
pnpm typecheck, pnpm testacross the workspace
pnpm lintbiome 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 dashboard

A 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).

Last updated 2026-10-30