These are the rules from the repository's CLAUDE.md and docs/ARCHITECTURE.md, written out
with the why and a code example each. They are not style preferences: most of them exist
because the platform is multi-tenant, white-labelled and partly operated by people who are
not developers. When a rule is enforced by a check, the check is named.
Tenancy: every query goes through forWorkspace
Rule. Every tenant-scoped read or write goes through forWorkspace(db, workspaceId) from
@kwisple/db. Never filter a tenant table by hand.
Why. All clients share one database and one schema; the only thing between one client's
data and another's is the workspace_id filter. forWorkspace owns that id, so a forgotten
filter is a type error instead of a leak, and it is the one place where Postgres row-level
security can be switched on later. It also hides trashed rows for the tables in trashTables
(social_post, design, asset, site), so a list can never show something that is in the
Prullenbak by accident.
import { eq, schema, type WorkspaceDb } from "@kwisple/db";
export async function listChannels(wdb: WorkspaceDb) {
return wdb.db
.select()
.from(schema.socialChannel)
.where(wdb.where(schema.socialChannel, eq(schema.socialChannel.enabled, true)));
}
export async function addChannel(wdb: WorkspaceDb, values: { name: string }) {
await wdb.db.insert(schema.socialChannel).values(wdb.row({ id: newId("socialChannel"), ...values }));
}ctx.wdb on a WorkspaceContext is already forWorkspace(db, workspace.id). Queries that
legitimately span workspaces (agency overviews, the dispatch sweep, the host lookup in
apps/web) use live(table) / liveWorkspace() / clientWorkspace() explicitly so the trash
filter is still applied. Details: Tenancy and permissions.
Content goes through @kwisple/content and the publish helper
Rule. Apps never query cms_* tables. Reads and writes go through @kwisple/content;
publishing in the dashboard goes through afterContentChange in
apps/dashboard/src/lib/services/cms-publish.ts.
Why. The engine normalises stored values against the current schema, validates on publish,
keeps URIs unique, writes redirects when a URI changes, records revisions and knows every cache
tag of a site. Caches in apps/web, the signed webhook to a custom-code site, the debounced
rebuild of static sites and the activity log all fire from one place; a write that bypasses it
leaves a visitor looking at a stale page for an hour.
const sctx = await loadSiteCtx(ctx.wdb, siteId);
const result = await publishEntry(sctx, { entryId, userId: ctx.user.id, snapshot });
if (result.ok) await afterContentChange(ctx.wdb, sctx.site, {
tags: result.tags, event: "cms.publish", actorId: ctx.user.id, subjectId: entryId,
});See The content engine.
Schema pushes never delete content
Rule. A content model push (kwisple push, the standard schema) may add and change
fields; it never deletes stored values. Reads are normalised against the current schema.
Why. Editors keep their work when a developer renames or removes a field by mistake; re-adding the field brings the content back. The same applies to database migrations: they add (expand → migrate → contract), so a rollback to the previous image keeps working on the newer schema.
Rich text is sanitised on publish
Rule. HTML from the editor passes the allowlist sanitiser in @kwisple/cms
(src/sanitize.ts: p, h2–h4, lists, strong, em, a, br, blockquote; safe
href protocols only) before it is stored as live content. SVG uploads are sanitised in
@kwisple/storage (prepareUpload).
Why. Content is rendered on public sites and inside the dashboard preview. Sanitising once, on the way in, keeps every renderer simple and the output unable to break page structure.
Server Components by default
Rule. Pages and most components are Server Components. Mark client components with
"use client" explicitly and keep them leaf-sized: the interactive bit (a toggle, a drag
handle), not the page.
Why. The dashboard measures its pages in 10–40 ms of server time and a handful of queries;
shipping a page as client JavaScript would multiply the bundle and move data loading into the
browser. Server Components also keep requireWorkspace/can() on the server where they belong.
// page.tsx (server)
const ctx = await requireWorkspace(workspace);
if (!can(ctx, "request.view")) redirect(NO_ACCESS);
const rows = await listRequests(ctx.wdb);
return <RequestList rows={rows} action={<RequestAdd workspace={ctx.workspace.slug} />} />;
// request-add.tsx (client, leaf)
"use client";
export function RequestAdd({ workspace }: { workspace: string }) { /* Sheet + ActionForm */ }No non-component exports from "use client" files
Rule. A "use client" module that server components import exports components only. Shared
constants, types-with-values and helpers live in a plain module.
Why. Every export of a client module arrives in a server component as a client reference,
not as the value. A constant exported next to a component becomes an opaque object on the
server and the bug is silent. components/ui/form-events.ts (the FORM_DISCARD_EVENT
constant) is a plain module for exactly this reason.
Images need alt text; brand colours pass WCAG AA
Rule. An image used in content is only publishable with alt text. Brand colours must pass
WCAG 2.2 AA for the pairings the brand kit declares; validateBrandKit in @kwisple/brand
checks it when the kit is saved.
Why. Quality is enforced by the system, not by discipline (ARCHITECTURE.md §1). The
platform promises accessible sites and social images; a missing alt or an unreadable button
colour would ship to every template and page at once.
import { contrastRatio, WCAG } from "@kwisple/brand";
if (contrastRatio(kit.colors.primary, kit.colors.background) < WCAG.AA_TEXT) { /* issue */ }Heavy work goes through @kwisple/jobs
Rule. PNG rendering, exports, mails, webhooks, zips and anything that takes more than a
moment is a job: enqueue(name, payload) in the request, a handler in
apps/dashboard/src/worker/start.ts.
Why. A request handler that renders with Satori and resvg blocks a Next.js worker for
seconds and fails without a retry. Jobs are retried with backoff, can be debounced
(singletonKey) and run in a process sized for them. See Jobs and the worker.
await enqueue("social.render", { workspaceId, postId, targetId: null, userId: ctx.user.id });The product name comes from product
Rule. Never hardcode the product name in UI. Use product from @kwisple/core
(packages/core/src/product.ts) or the agency's branding. Internal identifiers
(@kwisple/*, table names, cookie keys) are deliberately not derived from it.
Why. The name is a working title that may change, and clients must see their agency, not
the platform. The product shows itself in exactly three places: where no agency branding
applies (Platformbeheer, the main domain's login), the browser tab icon, and the small
"Powered by" credit in the sidebar (docs/DESIGN-SYSTEM.md §7). The assistant launcher is a
neutral pixel spark, not the product mark.
White-label identity
Rule. Clients see their agency: name, logo and accent colour from agency.branding
(resolveBranding in lib/branding.ts), their own sign-in page /login/<client>, and texts
that name the agency through the {agency} placeholder in messages. Never write "je bureau"
in a string a client sees.
Why. The agency stays the face (ARCHITECTURE.md §1.6). The accent override is the only
theming hook: brandingStyle() sets --color-accent / --color-accent-fg; everything else
in the design system stays.
Assistant through askAssistant; no model SDKs in the platform
Rule. The platform holds no model API keys and imports no model provider SDK. Assistant
features build a task with the protocol in @kwisple/core/assistant and send it to
ASSISTANT_URL (docs/ASSISTANT.md). The Kwisple Agent (apps/agent) is the only code that
talks to a model, behind the Model interface in apps/agent/src/model.ts, and it imports
nothing from the platform beyond @kwisple/core.
Why. The model can change without touching the product, an agency can run its own agent,
and client data never reaches a provider from the dashboard process. The only
@anthropic-ai/sdk dependency in the repository is in apps/agent/package.json.
MCP tools write proposals only
Rule. Every tool in apps/dashboard/src/lib/agent/tools.ts opens the workspace with
agentWorkspace() and checks can() like the dashboard would (requirePermission). Writes are
proposals (assistant_proposal, or an idea post marked suggestedBy = 'ai') or offline
preparations (create_campaign, create_page as a draft). Never published content, schedules,
approvals or the brand kit.
Why. An agent acts with exactly the rights of its person and never more; a proposal is
visible, labelled and reversible, a publish is not. test/agent/tools.test.ts calls every tool
with another client's workspace and fails if anything leaks or is written.
AI assists, never leads
Rule. AI never publishes, schedules or approves by itself. In the assistant chat the agent
may ask (request_publish_page, request_post_step → a publish.* proposal, chat tokens
only); only the person's click on the confirmation card acts, through the normal actions
(lib/actions/assistant-steps.ts → publishEntryAction / movePostAction). The brand itself
is never AI-generated.
Why. Approval flows are the product's promise to the client: what goes out was seen by a person. Keeping the confirming action identical to the button a person would click keeps one permission path and one audit trail.
Design system
Rule. Use the tokens and primitives in @kwisple/ui and the dashboard's own PageHeader,
TabNav, Sheet, ActionForm (with saveBar), EmptyState. Grey canvas, white sheet, ink,
one typeface (Inter Tight), one-colour .display headings, the type scale text-meta …
text-display, radii rounded-ui / rounded-card / rounded-full, PixelField artwork
with .glass. No serif or display fonts, no ad-hoc colours, no font-semibold, no text below
12 px; mono only for code-like values.
Principles. Show the thing (thumbnails, previews), not the record. Create via a header
button and a Sheet, not a permanently open form. Lists with hairlines instead of bordered
boxes or tables. One primary action per screen. Plain language; technical options behind
"Geavanceerd" and staff-only where possible.
Why. Consistency is what makes a white-labelled product feel finished for every agency,
and the tokens are the only thing an agency colour needs to re-theme. docs/DESIGN-SYSTEM.md
and Languages and design system have the full catalogue.
<PageHeader title={t("title")} description={t("description")} actions={<ChannelAdd … />} />
<ul className="divide-y divide-border">{rows.map((r) => <li key={r.id}><ListRow … /></li>)}</ul>Roles: can() and the access policy
Rule. Gate UI with can(ctx, key) from apps/dashboard/src/lib/permissions.ts: hide what
is not allowed, never show a disabled dead end. Server actions keep their own can() check.
Client rights come from the agency-configured access policy (areas × levels,
lib/access-policy.ts); a new permission key needs an area and level there, or agencyOnly.
"Wacht op jou" comes only from waitingForYou(ctx) (lib/services/inbox.ts).
Why. Screens, actions, route handlers, agent tools and the inbox must decide the same way,
and tools/check-action-permissions.mjs (pnpm check:actions) fails a server action that
resolves a workspace without a permission check. See
Tenancy and permissions.
Levels must be unmistakable
Rule. The agency environment has the dark lila frame (<AppShell level="agency">),
Platformbeheer the plain dark frame (level="platform"), a client account the light canvas.
Every page starts with the context bar (the path from your top level). Inside a client account
staff see "← Alle klanten", the client card with the ⌘K switcher, a band in the client's
colours and "Bekijk als klant" (a cookie that only ever reduces rights); clients see only
their own business with "Begeleid door
Why. An agency member works in dozens of client accounts a day; mixing up where you are is the most expensive mistake the UI can cause. Page headers therefore do not repeat the level and pages do not add their own back links.
Site modes and URL helpers
Rule. A site has a mode: headless (custom code in its own repository on our API; the
only mode offered for new websites), hosted (legacy block sites rendered by apps/web;
existing ones keep working, never offered for new sites) or external (built elsewhere; we
keep the URL and admin link, apps/web never serves it). Use publicSiteUrl(site) and
mainWebsiteUrl(wdb) from lib/services/sites.ts for addresses, never the platform subdomain
directly.
Why. The address of a site depends on its mode, its verified primary domain and
PLATFORM_DOMAIN; building it by hand in QR codes, signatures or links breaks the moment a
client connects a domain.
Client-specific work lives in the client's repo as an app
Rule. Booking desks, generators and other one-client tools live in the client's site
repository and reach the dashboard as an app (defineApp in its cms.config.ts, framed
with a signed session; docs/DEVELOPING-SITES.md "Apps"). Never add one client's feature to
the platform; add a generic capability (an API, a webhook event, a status) only when every
client could use it.
Why. One deployment serves every agency; a client-specific screen in the platform is dead weight for everyone else and a maintenance burden forever. The app frame gives the client the same menu, context bar and rights model with zero platform code.
Brand elements
Rule. Brand kit elements (patterns, illustrations, icons) are placed by social templates
and recoloured per background; patterns never run through text or the logo.
Why. Templates render into every platform format from one design; an element that may overlap text in one ratio will do so somewhere.
The handleiding is part of every feature
Rule. A new or changed screen, button or flow updates the page(s) of that module in
docs/handleiding (served as Hulp on /hulp), or adds a page linked from
docs/handleiding/README.md with front matter title, description, audience, section,
order, updated. Write for the reader of that page (client, agency or developer), in Dutch,
with the exact labels of the UI. pnpm test checks that every page is in the index and every
link resolves (apps/dashboard/test/help/manual.test.ts).
Why. The handleiding is the support desk; a feature nobody can find in Hulp generates a request to the agency instead.
Languages: Dutch source, French and English next to it
Rule. Every UI string lives in apps/dashboard/messages/nl.json (+ fr.json, en.json),
read with getT() (server) or useT() (client) from @/i18n/*; dates and numbers go through
getFormatter() / useFormatter(). Never hardcode copy or "nl-BE". pnpm check:i18n
guards it. Code, comments and documentation are English.
Why. Agencies in Belgium work in Dutch and French and the dashboard follows the person's own language setting; a hardcoded sentence is a bug in two of three languages. Details and the ICU subset: Languages and design system.
Formatting: Biome
Rule. pnpm lint runs biome check .; pnpm lint:fix writes fixes. Two spaces, double
quotes, semicolons, trailing commas, line width 100, organised imports (biome.json). The
a11y rule noLabelWithoutControl is an error and knows the dashboard's Input, Select,
Textarea, ColorInput and AssetPicker components. noExplicitAny is a warning; when a
registry needs it, write a // biome-ignore lint/suspicious/noExplicitAny: <reason> line.
Why. One formatter, no debates, and CI fails on anything it would change.