Each recipe lists the files to touch, in order, and the check that proves it works. They assume you know the rules in Conventions; the background for each system is on the "How it works" pages linked from every recipe. Most features combine several recipes: a new screen usually means a page, a server action, messages, maybe a permission key, and always a Hulp page.
A dashboard page
Example: a new screen at /w/<client>/verzoeken.
Route. Create
apps/dashboard/src/app/(app)/w/[workspace]/<segment>/page.tsx(agency screens underbureau/[agency]/…, Platformbeheer underadmin/…). Route segments are Dutch and lower case; they are URLs, not UI copy.Resolve and gate. A Server Component that awaits its params, resolves the context and checks a permission before loading any data:
export default async function Page({ params }: { params: Promise<{ workspace: string }> }) { const { workspace } = await params; const ctx = await requireWorkspace(workspace); if (!can(ctx, "request.view")) redirect(NO_ACCESS); const [t, rows] = await Promise.all([getT("requests"), listRequests(ctx.wdb)]); return ( <> <PageHeader title={t("title")} description={t("description")} actions={can(ctx, "request.create") ? <RequestAdd workspace={ctx.workspace.slug} /> : null} /> {rows.length ? <ul className="divide-y divide-border">…</ul> : <EmptyState title={t("emptyTitle")} />} </> ); }Agency pages use
requireAgency(slug)instead. AddgenerateMetadatawith a title from the catalogue.Data comes from
lib/services/*withctx.wdb; never query in the page itself.Interactive parts are small
"use client"leaves (a create button that opens aSheetwith anActionForm). Export only components from them.Context bar. Give the new segment a label in its layout's
labels(for client screensclientCrumbLabelsinapp/(app)/w/[workspace]/layout.tsx, with the text inshell.crumbs). Without one the page does not appear in the trail.Navigation. A new top-level module also needs a sidebar entry and a key in
moduleAccess(lib/access-policy.ts), the permission that makes it visible.Loading state. One
loading.tsxper navigation, next to the page (in a route group when a list and its detail would otherwise show two skeletons).Copy in
nl.json,fr.json,en.json(A message) and a page in the handleiding (A Hulp page).
Check: pnpm typecheck, pnpm check:i18n, open the page as staff and with
"Bekijk als klant" for a role that should not see it.
A server action
File. Add an exported
async functiontoapps/dashboard/src/lib/actions/<module>.ts(the file starts with"use server"). One file per module.Shape. Resolve, check, validate, write through a service, revalidate:
export async function createRequestAction( workspaceSlug: string, _: unknown, formData: FormData, ): Promise<ActionState> { const ctx = await requireWorkspace(workspaceSlug); const [t, tc] = await Promise.all([getT("requests"), getT("common")]); if (!can(ctx, "request.create")) return { error: tc("noRights") }; const parsed = schema.safeParse(Object.fromEntries(formData)); if (!parsed.success) return { error: t("invalid") }; await createRequest(ctx.wdb, parsed.data, actorOf(ctx)); revalidatePath(`${ctx.base}/verzoeken`); return { ok: true }; }ActionState(components/action-form.tsx) is{ error?, ok?, … }; bind the first argument in the client (action.bind(null, slug)) and pass it toActionForm.Errors in the caller's language: get
tinside the action, never return hardcoded Dutch.Heavy work is enqueued, not done here (A job).
Content changes call
afterContentChangeafter the engine wrote (The content engine).
Check: pnpm check:actions. It fails when an exported action that calls requireWorkspace(
(or the site helpers) has no recognised permission check; see
Tenancy and permissions.
A permission key
- Declare it in
permissionAreasinapps/dashboard/src/lib/access-policy.tswith its area and minimum level:"thing.do": { area: "website", level: "edit" }. Something a client must never get getsagencyOnly: true. Add a comment when the level is a judgement call. - Use it with
can(ctx, "thing.do")in the page (hide the control) and again in the action.Permissionis inferred from the record, so a typo is a type error. - Agency managers only? Add it to
agencyManagerOnlyinlib/permissions.ts(today onlyworkspace.deleteandworkspace.access). - Agent tool? If an MCP tool depends on it, add it to
TOOL_NEEDSinlib/agent/tools.tsso the tool is hidden from people who have it nowhere. - Handleiding. Update the "Wie kan dit?" line of the page that describes the feature.
Check: pnpm --filter @kwisple/dashboard test (test/access/* covers the policy and its
resolution; the resolution tests need Postgres).
A database table or column
Schema. Edit or add a file in
packages/db/src/schema/(export a new file fromschema/index.ts). A tenant table getsworkspaceId: workspaceRef()from_shared.ts, an index that starts withworkspace_id, and usually...timestamps; a table with a trash gets...trashColumns()and an entry intrashTables(packages/db/src/tenant.ts).Ids. Give the row kind a prefix in
idPrefixes(packages/core/src/ids.ts) and create ids withnewId("<kind>").Generate the migration from the root:
pnpm db:generate # drizzle-kit generate → packages/db/drizzle/NNNN_<name>.sql + meta/Read the SQL. It must only add: new tables, nullable columns or columns with a default, indexes. Renames and drops follow expand → migrate → contract over several releases, so the previous image keeps working on the newer schema during a deploy and a rollback.
Apply it locally:
pnpm db:migrateNever use
pnpm db:pushfor a change you commit; production only ever runsmigrate.Query it through
forWorkspace(wdb.where(table, …),wdb.row({...})).Content is never deleted by a schema change. A column that holds content is not dropped in the same release that stops using it, and CMS fields are never removed from the database by a model push at all (The content engine).
Clean-up paths: a new tenant table is removed with its client by the
on delete cascadeofworkspaceRef(); a new storage folder must live underworkspaces/<id>/so thestorage.purgeprefix delete covers it.
Check: pnpm typecheck, pnpm db:generate again (it must report nothing to generate), and the
database-backed tests.
A background job
The full recipe is on Jobs and the worker. In short: a Zod
payload in jobSchemas (packages/jobs/src/index.ts), a handler registered with work() in
apps/dashboard/src/worker/start.ts (and schedule() if recurring), enqueue() from the
service layer with the right retry and singleton options.
A webhook event
Outgoing webhooks are a public contract: add events, never rename them.
- Key. Add it to
webhookEventsinpackages/core/src/webhooks.ts(<thing>.<past tense>). - Label. Add
settings.webhooks.event.<key with _>(page.published→page_published) to the three catalogues; the endpoint form lists it from there. - Payload. Add a builder in
apps/dashboard/src/lib/services/webhook-events.tsthat checkshasWebhookListener(workspaceId, event)first (payloads are only built when an active endpoint listens), keeps the data small and stable, includes aurlback into the dashboard, and never throws (wrap it like the others withquietly). - Emit it from the service where the thing happens, with
emitWebhookEvent(workspaceId, event, data). That only queueswebhook.emit; the worker fans out and delivers with retries. Never emit for staging sites. - Document it in the event table of
docs/WEBHOOKS.md, in Outgoing webhooks and in the handleiding page on webhooks.
Check: pnpm --filter @kwisple/dashboard test (test/webhooks.test.ts,
test/sites/webhook-delivery.test.ts), then Test versturen from Instellingen → Webhooks to
a request bin.
An MCP tool
- Register it in
createAgentServerinapps/dashboard/src/lib/agent/tools.tswithserver.registerTool(name, { title, description, inputSchema, annotations }, handle(async (a) => …)). Take the workspace asworkspace: workspaceArg. The description is English and model-facing (// i18n-ignore: model-facing tool description). - Open and check like the dashboard:
const ctx = await open(a.workspace)(that isagentWorkspace(principal, slug)), thenrequirePermission(ctx, "<key>", "<what>"). - Write only proposals:
createProposal(ctx.wdb, { kind, subjectId, payload, createdBy, agentTokenId }), anideapost withsuggestedBy: "ai", or an offline preparation (a draft page, a campaign). Never publish, schedule, approve or touch the brand kit. Read-only tools setannotations: { readOnlyHint: true }. - Hide it from people who cannot use it anywhere: add the permission(s) to
TOOL_NEEDS. - Errors for the agent are
AgentErrormessages;handle()turns anything else into a generic failure. - Document it in the tool table of
docs/AGENT.mdand in MCP connectors and OAuth.
Check: test/agent/tools.test.ts lists every tool by name (add yours) and calls the tools with
another client's workspace; it must leak and write nothing. It needs Postgres.
A message in nl, fr and en
- Add the Dutch text to
apps/dashboard/messages/nl.jsonunder the screen's namespace. - Add the same key, with the same
{variables}, tofr.jsonanden.json. - Use it with
t("key")fromgetT("<ns>")oruseT("<ns>"); on the client, check that aClientMessagesabove the component passes the namespace. - Name the agency with
{agency}, never "je bureau".
Check: pnpm check:i18n. Details: Languages and design system.
A UI primitive
Only when something is used across modules and fits the design system; screen-specific pieces
stay in apps/dashboard/src/components.
- File.
packages/ui/src/<name>.tsx, exported frompackages/ui/src/index.ts. - Tokens only: colours, radii, type scale and motion tokens from
styles.css; merge classes withcn(). No hex values, nofont-semibold, nothing under 12px. - Server by default. Mark it
"use client"only if it needs state or effects, and keep the client part small. - Language-neutral. No catalogue imports: take text as props, and put any built-in word in
uiLabels(packages/ui/src/locale.ts) fornl,franden, read throughUiLocaleProvider(client) or alocaleprop (server). - Accessible: a real label for every control (
IconButtonrequires one), focus styles, and a reduced-motion variant for any transform animation. - Show it in the gallery on
/admin/design(apps/dashboard/src/app/(app)/admin/design/) and describe it indocs/DESIGN-SYSTEM.md.
Check: pnpm --filter @kwisple/ui test (packages/ui/test/), pnpm typecheck.
A Hulp page
The handleiding in docs/handleiding is served as Hulp on /hulp, with search.
File.
docs/handleiding/<section folder>/<page>.md, in Dutch, written for its reader, with the exact labels of the UI in bold.Front matter:
--- title: Verzoeken description: Een vraag aan je bureau stellen en het antwoord volgen. audience: klant # klant, bureau or developer section: Aan de slag order: 5 updated: 2026-10-30 ---audience: klantpages are visible to clients;bureauanddeveloperpages only to the team.Index. Link it from
docs/handleiding/README.mdunder the right group and section.Links between pages are relative
.mdlinks (../team/rollen-en-rechten.md#rollen); they become/hulp/...addresses.
Check: pnpm --filter @kwisple/dashboard test runs test/help/manual.test.ts, which fails
when a page is missing from the index or a link points at a page that does not exist.
A developer docs page
This documentation lives in docs/developers and is built into a static site by apps/docs.
- File.
docs/developers/<group>/<page>.md, in English, for developers. - Front matter with
title(identical to the H1),description(one sentence),section,order(a number above 0) andupdated(YYYY-MM-DD). The body starts with# <title>. - Index. Add a numbered link in
docs/developers/README.mdunder its##group and###section; the README's layout is the navigation. - Links are relative
.mdlinks, optionally with an anchor. Anchors are the heading text in lower case, punctuation removed, spaces turned into hyphens. - Only verified facts. Commands, paths and variables must exist in the repository.
Check: pnpm --filter @kwisple/docs test (apps/docs/test/content.test.ts): every page has
front matter and an H1 equal to its title, is listed in the index exactly once, links only to
pages and headings that exist, and contains no "to verify" marker left by a draft. Preview with
pnpm --filter @kwisple/docs dev on port 4106.
A delivery API endpoint
Only for something every client site could use; one client's feature belongs in its own repository as an app.
Route.
apps/web/src/app/api/v1/<name>/route.ts:import { findEntries } from "@kwisple/content"; import { apiError, authenticate, cachedRead, handle, json, preflight } from "@/lib/api/http"; export async function GET(req: Request) { return handle(async () => { const auth = await authenticate(req, "read"); if (auth instanceof Response) return auth; const sp = new URL(req.url).searchParams; const section = sp.get("section"); if (!section) return apiError(400, "bad_request", "…"); const data = await cachedRead(auth.ctx, ["<name>", section], () => findEntries(auth.ctx, { section, status: "live" }), ); return json(data, { cache: "public" }); }); } export const OPTIONS = preflight;Scope.
readfor reads,previewfor draft access,schemafor anything that changes the model or seeds content. Add a scope only by extendingCmsTokenScopeinpackages/db/src/schema/cms.tsand the token UI.Reads go through
@kwisple/contentand throughcachedRead, so a publish purges them (cms:site:<id>); pass extra tags when the data has its own (brand:<ws>).Rate limit endpoints that a browser or a visitor action can hit: add a rule to
publicRulesinapps/web/src/lib/public-limits.ts(capacity,refillPerSecond) and callrateLimitPublic(req, "<scope>", site.id). Cap request bodies withreadJsonLimited.SDK method (below) and the page Delivery API.
Check: pnpm typecheck; call it with curl -H "Authorization: Bearer <site token>" against
http://localhost:4101/api/v1/<name>.
An SDK method
Interface and implementation in
packages/sdk/src/client.ts: add the method to theKwispleClient<S>interface (typed against the schemaSwhere it returns content) and to theclientobject, built oncall<T>(path, { method, body, allow404, preview, noStore, tags, revalidate }). GET calls are cached in the Next data cache with thekwisple:alltag, so the publish webhook purges them; passnoStorefor anything personal.Astro or Next helpers in
packages/sdk/src/astroorsrc/nextwhen the method needs one.Tests in
packages/sdk/test/client.test.ts(fetch is injected, no platform needed).Docs: SDK client.
Build and dry-run the published packages:
pnpm --filter @kwisple/sdk test pnpm release:dry # builds @kwisple/cms, @kwisple/sdk, create-kwisple-site; npm pack --dry-runSites that vendor the tarballs. Until the packages are on npm, a site repository installs them from its own
vendor/folder. Write fresh tarballs there and reinstall in the site:pnpm release:pack ../acme-site/vendor
An analytics dimension
Statistics are first-party and aggregated nightly (docs/ANALYTICS.md).
- Raw data. If the dimension needs a new field on the event, add the column to
analytics_event(A database table or column) and fill it in the collector (packages/analytics/src/collect.ts, reached throughapps/web's/api/v1/collect). - Rollup. Add a
union allbranch to the aggregate select inpackages/analytics/src/rollup.tsthat emitsdim = '<name>'with itsvalueand counts. The same select serves today's live numbers, and the rollup deletes and rebuilds a day, so re-running it backfills the window of raw events still kept (30 days). - Queries. Add the name to
TopDimandTOP_DIMSinpackages/analytics/src/stats.tsif it is a "top N" list, or read it in the query that needs it. - Screen. Show it on Statistieken in the dashboard with labels from the catalogue.
- Docs. Add it to the
analytics_dailyrow indocs/ANALYTICS.mdand the handleiding page on statistics.
Check: pnpm --filter @kwisple/analytics test (test/db.test.ts runs the rollup against
Postgres, test/pure.test.ts the pure helpers).