An app is declared in cms.config.ts with defineApp and listed in defineSchema({ apps }).
It travels with the content model: kwisple push (or kwisple dev, into the development model)
makes the dashboard aware of it, and the menu item appears for everyone who may open it.
import { defineApp, defineSchema } from "@kwisple/sdk";
const boekingen = defineApp({
handle: "boekingen",
name: "Boekingen",
description: "Aanvragen van de website bevestigen, afwijzen of een ander moment voorstellen.",
icon: "calendar-check",
path: "/beheer/boekingen",
// Bookings hold names and addresses: the same people who read the form inbox.
area: "website",
level: "edit",
});
const golfgenerator = defineApp({
handle: "golfgenerator",
name: "Golfgenerator",
description: "De pixelgolf uit het brandbook, als PNG of SVG in elk formaat.",
icon: "waves",
path: "/beheer/golfgenerator",
area: "brand",
level: "edit",
});
export default defineSchema({
apps: [boekingen, golfgenerator],
sections: [/* … */],
});(These are the two apps of the RANSON.N site; see the booking desk and generator examples.)
defineApp options
defineApp(def: AppDef) returns the definition unchanged; it exists for typing (the handle
becomes a literal type) and for readability. Validation happens when the schema is pushed.
| Option | Type | Default | Meaning |
|---|---|---|---|
handle | string | required | Address segment in the dashboard: /w/<client>/apps/<handle>. Lowercase letter first, then lowercase letters, digits and -, at most 40 characters (^[a-z][a-z0-9-]{0,39}$). Must be unique within the schema. |
name | string | required | The name in the navigation and in the page header above the app. |
description | string | "" | One line under the name (page header and the Apps list). |
icon | string | none | A Lucide icon name for the menu, e.g. "waves", "calendar-check". Any Lucide name works; without one the dashboard shows a generic icon. |
path | string | required | The app's page on the site, relative to the site's URL, e.g. /beheer/boekingen. Must start with a single / and may carry a query string; no fragment, no whitespace, never // (a path can never point the frame at another site). |
area | AppArea | required | The area of the client's access policy the app belongs to: website, stats, brand, media, social, studio or toolkit. Decides which client roles see it. |
level | AppLevel | "edit" | The minimum level a client user needs in area: view < suggest < approve < edit < manage. |
teamOnly | boolean | false | Only the agency team sees the app. Clients never do, whatever their level. |
Exported types: AppDef, AppArea, AppLevel, and the constant arrays appAreas and
appLevels (from @kwisple/cms; AppDef, AppArea and AppLevel are re-exported by
@kwisple/sdk). appLevelAtLeast(level, needed) compares two levels and is what your server
uses to gate actions (see Server side).
Validation
kwisple push (and kwisple dev) validate the whole schema before sending it, apps included.
These problems stop the push, each with a sentence naming the app:
- a handle with characters outside
[a-z0-9-], or starting with a digit or-; - the same handle declared twice in one schema;
- an empty
name; - a
paththat is not a path on the site (must start with/, no//, no spaces, no#); - an unknown
areaorlevel.
Who sees an app: areas, levels and appLevel()
Client rights on the platform come from the access policy the agency configures per client under Instellingen → Toegang ("Settings → Access"): for each role, a level per area. Apps plug into that same model, so a client never gets a new kind of right just for an app.
| Area | Dashboard label | Levels the agency can give |
|---|---|---|
website | Website-inhoud | Verborgen, Bekijken, Voorstellen, Bewerken, Beheren |
stats | Statistieken | Verborgen, Bekijken |
brand | Merk | Verborgen, Bekijken, Bewerken |
media | Media | Verborgen, Bekijken, Bewerken, Beheren |
social | Social | Verborgen, Bekijken, Goedkeuren, Bewerken, Beheren |
studio | Studio | Verborgen, Gebruiken (= edit) |
toolkit | Hulpmiddelen | Verborgen, Gebruiken (= edit) |
Level keys: hidden (Verborgen), view (Bekijken), suggest (Voorstellen),
approve (Goedkeuren), edit (Bewerken, shown as Gebruiken for Studio and
Hulpmiddelen), manage (Beheren). hidden never opens an app. Pick an area whose
available levels include the level you ask for: an app with area: "stats", level: "edit"
can never be opened by a client, because no role gets edit in Statistieken.
appLevel(ctx, app) in the dashboard (apps/dashboard/src/lib/permissions.ts) returns the level
a person works with in an app, or null when the app is not for them:
- Agency team (staff): always
manage, for every app,teamOnlyor not. - Client users:
teamOnlyapps givenull. Otherwise the person's level inapp.areafrom their role;hiddenor no grant givesnull. - Read-only subscription: while the client's subscription is read-only, the level is
capped to
viewfor everyone, staff included. An app that asks for more thanviewis then not available; an app withlevel: "view"opens withview. - The result must reach
app.level ?? "edit"(appLevelAtLeast), elsenull.
The level is put in the signed session (session.level), so your server sees the same value
the dashboard used. team: true in the session marks agency staff.
Bekijk als klant ("View as client") applies here too: when a staff member views the dashboard as a client role, the apps and levels are those of that role. The apps API caps a request to the session's level, so the reduced rights hold on the server as well.
Choosing area and level
- Does the app show personal data from forms? Use
area: "website"with at leastedit: the apps API also requires the form-inbox right (site.forms, which is Website-inhoud → Bewerken) for reading submissions, so a lower level would open an app that cannot load. - Is it a brand tool (generators, exports)?
area: "brand",level: "edit"(or"view"if looking is enough). - Internal tooling for the agency?
teamOnly: true;areais still required and only matters for the menu grouping of the declaration (clients never see it).
Pushing the schema so the app appears
npx kwisple push # live model: the app appears for everyone who may open it
npx kwisple dev # development model only: nothing changes for editors or clientsApps are read from the live content model of the client's custom-code sites
(mode = headless). An app declared only in the development model (what kwisple dev syncs)
is not shown until kwisple push promotes it. Adding, renaming or removing an app is never a
destructive schema change: it does not touch content and is never blocked.
The menu item and the URL
- Every app gets its own menu item under Apps in the client's navigation, with its
iconandname. The Apps page (/w/<client>/apps) lists them all withdescription; it is a 404 for people who may open none. - The app opens at
/w/<client>/apps/<handle>, where<client>is the workspace slug. The page header showsnameanddescription; the frame fills the rest of the screen (calc(100dvh - 15rem), at least 520 px high). Do not repeat the title inside your page. - Apps are also findable with ⌘K (Ctrl+K on Windows) by name.
- Order: sites by name, then apps in the order of the
appsarray.
"Deze app is nog niet bereikbaar"
The frame needs an address and a secret to sign the session with. When either is missing the app is listed but opens on an empty state:
- agency team: Deze app is nog niet bereikbaar — De website van deze app heeft nog geen adres of is nog niet gekoppeld. Vul het adres in bij Website → Instellingen en koppel de site met kwisple link.
- clients: Probeer het later opnieuw of vraag het aan
.
Causes, checked in workspaceApps() (apps/dashboard/src/lib/apps.ts):
- the site has no webhook secret yet:
kwisple linkcreates one the first time a project is linked; the dashboard also creates one when a publish webhook URL is saved under Website → Instellingen → Ontwikkeling; - the Site-URL is empty or not
http(s); pathresolves to another origin than the site's (for example apathwith a scheme).
Several sites declaring the same handle
A client can have several custom-code sites. Apps are collected per site, sites sorted by name, and the first site that declares a handle owns it: a second declaration of the same handle on another site is skipped silently. Give apps distinct handles across the client's sites, or accept that only one site's app is shown. Within one site a duplicate handle is a validation error.
Staging environments are never considered (listSites leaves them out), so the staging
deployment's schema cannot shadow production's apps.
Read-only subscriptions
While a client's subscription has lapsed into read-only mode, the dashboard still shows apps,
but only those whose level is view, and everyone opens them with level: "view". Design an
app that should keep working in that state so its reads need view and its writes check
appLevelAtLeast(session.level, "edit") on the server (see Server side): the
app then degrades to looking instead of disappearing.
Checklist
defineApp({...})incms.config.ts, added todefineSchema({ apps: [...] }).npx kwisple pushfrom the site repo (CI does it onmain, see CI).- Site linked (
kwisple link) and Site-URL filled in. - The page exists at
pathand allows framing by the dashboard (Building the page). - Open
/w/<client>/apps/<handle>as a staff member, then as a client role with and without the level (Bekijk als klant).