Kwisple
Browse the docs

The platform repository › Getting around

Conventions

The rules the platform code follows, each with the reason behind it and a short example from the codebase.

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.

Last updated 2026-10-30