Kwisple
Browse the docs

The platform repository › Working on it

Adding things: recipes

Step-by-step recipes with file paths for the changes you make most often in the platform repository, from a dashboard page to a delivery API endpoint.

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.

  1. Route. Create apps/dashboard/src/app/(app)/w/[workspace]/<segment>/page.tsx (agency screens under bureau/[agency]/…, Platformbeheer under admin/…). Route segments are Dutch and lower case; they are URLs, not UI copy.

  2. 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. Add generateMetadata with a title from the catalogue.

  3. Data comes from lib/services/* with ctx.wdb; never query in the page itself.

  4. Interactive parts are small "use client" leaves (a create button that opens a Sheet with an ActionForm). Export only components from them.

  5. Context bar. Give the new segment a label in its layout's labels (for client screens clientCrumbLabels in app/(app)/w/[workspace]/layout.tsx, with the text in shell.crumbs). Without one the page does not appear in the trail.

  6. 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.

  7. Loading state. One loading.tsx per navigation, next to the page (in a route group when a list and its detail would otherwise show two skeletons).

  8. 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

  1. File. Add an exported async function to apps/dashboard/src/lib/actions/<module>.ts (the file starts with "use server"). One file per module.

  2. 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 to ActionForm.

  3. Errors in the caller's language: get t inside the action, never return hardcoded Dutch.

  4. Heavy work is enqueued, not done here (A job).

  5. Content changes call afterContentChange after 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

  1. Declare it in permissionAreas in apps/dashboard/src/lib/access-policy.ts with its area and minimum level: "thing.do": { area: "website", level: "edit" }. Something a client must never get gets agencyOnly: true. Add a comment when the level is a judgement call.
  2. Use it with can(ctx, "thing.do") in the page (hide the control) and again in the action. Permission is inferred from the record, so a typo is a type error.
  3. Agency managers only? Add it to agencyManagerOnly in lib/permissions.ts (today only workspace.delete and workspace.access).
  4. Agent tool? If an MCP tool depends on it, add it to TOOL_NEEDS in lib/agent/tools.ts so the tool is hidden from people who have it nowhere.
  5. 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

  1. Schema. Edit or add a file in packages/db/src/schema/ (export a new file from schema/index.ts). A tenant table gets workspaceId: workspaceRef() from _shared.ts, an index that starts with workspace_id, and usually ...timestamps; a table with a trash gets ...trashColumns() and an entry in trashTables (packages/db/src/tenant.ts).

  2. Ids. Give the row kind a prefix in idPrefixes (packages/core/src/ids.ts) and create ids with newId("<kind>").

  3. 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.

  4. Apply it locally:

    pnpm db:migrate

    Never use pnpm db:push for a change you commit; production only ever runs migrate.

  5. Query it through forWorkspace (wdb.where(table, …), wdb.row({...})).

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

  7. Clean-up paths: a new tenant table is removed with its client by the on delete cascade of workspaceRef(); a new storage folder must live under workspaces/<id>/ so the storage.purge prefix 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.

  1. Key. Add it to webhookEvents in packages/core/src/webhooks.ts (<thing>.<past tense>).
  2. Label. Add settings.webhooks.event.<key with _> (page.published → page_published) to the three catalogues; the endpoint form lists it from there.
  3. Payload. Add a builder in apps/dashboard/src/lib/services/webhook-events.ts that checks hasWebhookListener(workspaceId, event) first (payloads are only built when an active endpoint listens), keeps the data small and stable, includes a url back into the dashboard, and never throws (wrap it like the others with quietly).
  4. Emit it from the service where the thing happens, with emitWebhookEvent(workspaceId, event, data). That only queues webhook.emit; the worker fans out and delivers with retries. Never emit for staging sites.
  5. 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

  1. Register it in createAgentServer in apps/dashboard/src/lib/agent/tools.ts with server.registerTool(name, { title, description, inputSchema, annotations }, handle(async (a) => …)). Take the workspace as workspace: workspaceArg. The description is English and model-facing (// i18n-ignore: model-facing tool description).
  2. Open and check like the dashboard: const ctx = await open(a.workspace) (that is agentWorkspace(principal, slug)), then requirePermission(ctx, "<key>", "<what>").
  3. Write only proposals: createProposal(ctx.wdb, { kind, subjectId, payload, createdBy, agentTokenId }), an idea post with suggestedBy: "ai", or an offline preparation (a draft page, a campaign). Never publish, schedule, approve or touch the brand kit. Read-only tools set annotations: { readOnlyHint: true }.
  4. Hide it from people who cannot use it anywhere: add the permission(s) to TOOL_NEEDS.
  5. Errors for the agent are AgentError messages; handle() turns anything else into a generic failure.
  6. Document it in the tool table of docs/AGENT.md and 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

  1. Add the Dutch text to apps/dashboard/messages/nl.json under the screen's namespace.
  2. Add the same key, with the same {variables}, to fr.json and en.json.
  3. Use it with t("key") from getT("<ns>") or useT("<ns>"); on the client, check that a ClientMessages above the component passes the namespace.
  4. 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.

  1. File. packages/ui/src/<name>.tsx, exported from packages/ui/src/index.ts.
  2. Tokens only: colours, radii, type scale and motion tokens from styles.css; merge classes with cn(). No hex values, no font-semibold, nothing under 12px.
  3. Server by default. Mark it "use client" only if it needs state or effects, and keep the client part small.
  4. Language-neutral. No catalogue imports: take text as props, and put any built-in word in uiLabels (packages/ui/src/locale.ts) for nl, fr and en, read through UiLocaleProvider (client) or a locale prop (server).
  5. Accessible: a real label for every control (IconButton requires one), focus styles, and a reduced-motion variant for any transform animation.
  6. Show it in the gallery on /admin/design (apps/dashboard/src/app/(app)/admin/design/) and describe it in docs/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.

  1. File. docs/handleiding/<section folder>/<page>.md, in Dutch, written for its reader, with the exact labels of the UI in bold.

  2. 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: klant pages are visible to clients; bureau and developer pages only to the team.

  3. Index. Link it from docs/handleiding/README.md under the right group and section.

  4. Links between pages are relative .md links (../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.

  1. File. docs/developers/<group>/<page>.md, in English, for developers.
  2. Front matter with title (identical to the H1), description (one sentence), section, order (a number above 0) and updated (YYYY-MM-DD). The body starts with # <title>.
  3. Index. Add a numbered link in docs/developers/README.md under its ## group and ### section; the README's layout is the navigation.
  4. Links are relative .md links, optionally with an anchor. Anchors are the heading text in lower case, punctuation removed, spaces turned into hyphens.
  5. 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.

  1. 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;
  2. Scope. read for reads, preview for draft access, schema for anything that changes the model or seeds content. Add a scope only by extending CmsTokenScope in packages/db/src/schema/cms.ts and the token UI.

  3. Reads go through @kwisple/content and through cachedRead, so a publish purges them (cms:site:<id>); pass extra tags when the data has its own (brand:<ws>).

  4. Rate limit endpoints that a browser or a visitor action can hit: add a rule to publicRules in apps/web/src/lib/public-limits.ts (capacity, refillPerSecond) and call rateLimitPublic(req, "<scope>", site.id). Cap request bodies with readJsonLimited.

  5. 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

  1. Interface and implementation in packages/sdk/src/client.ts: add the method to the KwispleClient<S> interface (typed against the schema S where it returns content) and to the client object, built on call<T>(path, { method, body, allow404, preview, noStore, tags, revalidate }). GET calls are cached in the Next data cache with the kwisple:all tag, so the publish webhook purges them; pass noStore for anything personal.

  2. Astro or Next helpers in packages/sdk/src/astro or src/next when the method needs one.

  3. Tests in packages/sdk/test/client.test.ts (fetch is injected, no platform needed).

  4. Docs: SDK client.

  5. 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-run
  6. Sites 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).

  1. 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 through apps/web's /api/v1/collect).
  2. Rollup. Add a union all branch to the aggregate select in packages/analytics/src/rollup.ts that emits dim = '<name>' with its value and 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).
  3. Queries. Add the name to TopDim and TOP_DIMS in packages/analytics/src/stats.ts if it is a "top N" list, or read it in the query that needs it.
  4. Screen. Show it on Statistieken in the dashboard with labels from the catalogue.
  5. Docs. Add it to the analytics_daily row in docs/ANALYTICS.md and 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).

Last updated 2026-10-30