Kwisple
Browse the docs

Apps: custom modules per client

Apps overview

Custom work for one client, built in that client's site repository and shown in their dashboard as an app with the dashboard's frame, rights and data around it.

An app is a tool that only one client needs: a booking desk that confirms appointment requests, a generator that renders brand artwork, a price calculator. It lives in the client's own site repository, next to the pages of the site, and appears in that client's dashboard under Apps with the dashboard's navigation, context bar and client colours around it. Other clients never see it. The platform itself stays generic.

This section explains what an app is, how the pieces fit, and how to build one. The two worked examples, a booking desk and a generator tool, walk through the real code of the RANSON.N site.

App or platform feature?

The rule the platform follows: client-specific work is an app; a platform capability is only added when every client could use it.

Build an app when…Ask for a platform feature when…
One business needs it (its own booking rules, its own brand generator, its own data model).Every client of every agency could use it the same way (a new field type, a new webhook event, a new status on submissions).
The logic belongs to the site anyway (it already knows the availability, the mails, the layout).It needs data an app cannot reach today (see "What apps can reach" below).
You want to ship it today, in the site's own deploy.It must appear in the dashboard's own screens (Formulieren, Social, Merk), not under Apps.

An app is ordinary code in your repository: you deploy it with the site, you test it like the site, you own its design within the client's brand. What the platform adds is the place in the menu, the identity of the person using it, their rights, and a small API to act on platform data as that person.

What apps can reach today

  • Who is using it. A signed session with the person (user), the client (workspace), whether they are agency team (team), their level in the app's area and the dashboard locale. See Server side for the full shape.
  • Form submissions of the site. The apps API (/api/apps/v1/submissions) lists one form's submissions and lets the app follow them up: a status (the same Opvolging statuses the form inbox shows), a claimed moment (slot) and a mail to the visitor in the business's name. This is the only platform data the apps API exposes so far.
  • Everything the delivery API already offers, with the site's own API token: content, globals, formSlots, submitForm. The session says who is acting; the site's token is how the site reads and writes, as it does elsewhere. See Delivery API and Forms, bookings and confirmations.

Limits today

  • The dashboard frames the app from the site's production URL (the Site-URL under Website → Instellingen → Ontwikkeling). A site without an address, or one that is not linked yet (no webhook secret), shows the agency team Deze app is nog niet bereikbaar ("This app is not reachable yet") with what is missing; clients see a neutral "try later" text. There is no separate development URL for the frame.
  • Agencies on their own white-label dashboard host need that host in the site's frame-ancestors and in dashboardOrigin (it takes a list). See Security model.
  • Staging environments never declare apps: apps come from production sites only.
  • Platform data beyond form submissions (entries, media, posts) is not available through the apps API yet. Read it with the site's token through the delivery API instead.

The mental model

Four steps, each on its own layer:

  1. Declare the app in cms.config.ts with defineApp and push the content model. The dashboard now knows the handle, the name, the page on the site and who may open it. → Declaring an app
  2. Page: build the page at path in your site. In the browser it calls connectApp() from @kwisple/sdk/app, which asks the dashboard for the session and attaches it to the app's own requests. → Building the page
  3. Server checks: every request the page makes to your server is verified with appSessionFrom() (or appPlatform()), which checks the signature, expiry and app handle. Your server, not the browser, decides what the person may do, based on session.level. → Server side
  4. Platform data: with appPlatform() the server acts on the platform as that person (their live rights, never more), through the apps API on the dashboard. → Server side
cms.config.ts ──push──▶ dashboard menu "Apps"
                              │ opens /w/<client>/apps/<handle>
                              ▼
                      <iframe src="https://site/beheer/…">  ◀── your page (connectApp)
                              │ postMessage: signed session        │ fetch + Bearer
                              ▼                                    ▼
                      dashboard renews it            your server (appSessionFrom / appPlatform)
                                                                   │ Bearer = the same session
                                                                   ▼
                                                     dashboard /api/apps/v1/* (rights re-checked)

What happens when someone opens an app

  1. The person clicks the app in the menu (or on the Apps page, or finds it with ⌘K). The dashboard route /w/<client>/apps/<handle> loads.
  2. The dashboard reads the live content models of the client's custom-code sites and finds the app by handle. appLevel() decides whether this person may open it and with which level; if not, the page does not exist for them (a 404, never a disabled button).
  3. The dashboard signs a session for this person and this app with a key derived from the site's webhook secret, valid for 15 minutes, and renders the page header (the app's name and description) plus an <iframe> pointing at <site URL><path>.
  4. Your page loads inside the frame. connectApp() posts kwisple:app:ready to the parent every 500 ms until it answers.
  5. The dashboard answers with kwisple:app:session and the token, sent only to the site's exact origin. connectApp().ready resolves with the session claims; your UI can show the person's name and adapt to level.
  6. Your page calls its own API with app.fetch(...), which adds Authorization: Bearer <token>. Your server verifies the token (appSessionFrom / appPlatform) and, when it needs platform data, forwards the same bearer to /api/apps/v1/* on the dashboard, which checks everything again: signature, expiry, the person's live rights, and that the app is still theirs to open.
  7. Every 10 minutes the dashboard renews the session (a server action) and posts the new token into the frame; connectApp().onSession() listeners fire. When the person has lost access, or the site is no longer linked, renewal returns nothing, the frame is replaced by Deze app is gesloten omdat je sessie afliep of je geen toegang meer hebt. Laad de pagina opnieuw om verder te gaan., and any later request with the old token fails as soon as it expires.

Opened outside the dashboard (someone pastes the site URL in a tab), ready rejects: the page should say that the tool lives in the dashboard, and nothing else.

Where things live

PieceWhere
defineApp, AppDef, appLevelAtLeast, AppSession, verifyAppSession@kwisple/cms (re-exported from @kwisple/sdk)
connectApp (browser)@kwisple/sdk/app
appSessionFrom, appPlatform (server)@kwisple/sdk
Dashboard: menu, frame, session signingapps/dashboard/src/lib/apps.ts, components/apps/app-frame.tsx
Dashboard: apps APIapps/dashboard/src/app/api/apps/v1/**, lib/apps-api.ts
Who may open an appappLevel() in apps/dashboard/src/lib/permissions.ts

The client-facing explanation of apps is in the handleiding (Hulp) under "Apps van je zaak".

Last updated 2026-10-30