From template to release
The whole path of a new app: create it from the template with pnpm app:new , write the widget, config, texts, and images, check it with pnpm app:check , test it, and take it through the pull request into the catalog up to the release in Stable.
14 minute readApps SDKUpdated: 10/25/2026
On this page
Before you start
- For
- Developers who build a new app in the platform’s source tree. The SDK is prepared but not published. Apps from outside come only with the opening to publishers.
- Prerequisite
- a working copy of the source tree after
pnpm installandpnpm build, Node 24. For the release, two operator accounts with the permissionsplatform.app-catalog.importandplatform.app-catalog.promote.
After this guide, your app is a version in the Stable channel and therefore in the store of every organization whose package includes it. The path begins with a command that creates the app from a template and ends with the confirmation of a second person in the Ops portal. In between, a second command tells you at any time what is still missing.
The path in one picture
pnpm app:new <id> ─ Widget, Config, Formular, Texte, Bilder ─ pnpm app:check <id> ─ Tests
└─► Pull Request mit Notiz ─ Versions-PR ─ Zug: Tag @szenera-app/<id>@<fassung>
└─► release-app.yml: bauen, aufnehmen, packen, prüfen, signieren, ablegen, melden
└─► Staging und Produktion: Release im Kanal Canary
└─► Ops-Portal: Release-Prüfung ⇒ Beta ⇒ Stabil, eine zweite Person bestätigt
The words on this page follow the glossary: the version is the number that an organization reads (1.0.0), the release is the same version in the operator’s catalog, and the channel says who gets it.
1. Create the app
pnpm app:new door-sign --name="Türschild" --name-en="Door sign"
pnpm install
pnpm --filter @szenera-app/door-sign build
pnpm app:new <id> creates apps/<id>/ from the template and writes nothing else: no list, no entry in any file of the platform. Platform, catalog, screenshots, and budgets find the app through its folder.
- The identifier is kebab-case and English (
door-sign, nottuerschild). It applies to the folder, the package@szenera-app/<id>, the manifest, the template’s one widget, and the database schemaapps_<id>(with an underscore instead of a hyphen). The command rejects an identifier that is taken, reserved (core), or malformed, naming every reason at once, and then creates nothing. - The names given under
--name(German) and--name-en(English) go into the manifest. Without them, the command temporarily takes a name from the identifier, and you rewrite the texts anyway. pnpm installadds the app to the workspace. Afterward the lockfile is in compact form:pnpm exec prettier --write pnpm-lock.yamlbrings it into the tree’s format, andpnpm lizenzhinweisewrites the third-party notices with the new state of the lockfile.pnpm --filter @szenera-app/<id> buildbuilds the app. The catalog seed reads its built manifest and skips it with a notice until then.
What the template brings:
| Part | Files | What is in it |
|---|---|---|
| Manifest | src/manifest/index.ts |
Identifier and version, texts in German and, under translations, in English, category, class, store block, compatibility (hosts, orientations, behavior without a network), and apiCompatibility.rendererHost from RENDERER_HOST_RANGE |
| Widget | src/widgets/<id>/ |
definition.ts, config.ts, config-ui.ts, runtime.tsx, authoring.tsx, layout.ts |
| Entry points | src/runtime/, src/authoring/ |
registerWidgets and registerAuthoring |
| Tests | tests/, testing/ |
Manifest, compatibility with the baseline tests/compat-baseline.json, widget and form via two render harnesses |
| Build | package.json, tsup.config.ts, vite.config.*.ts |
exports for manifest, runtime, and authoring, the scripts, and the size caps of the bundles under szenera.bundleBudgets |
| Images | icon.svg, thumbnails/ |
Placeholders that pnpm app:check shows red until icon and screenshots are real |
An app without a widget (one that unlocks a feature of the portal) or with a backend builds on the same template: ./runtime and ./authoring are dropped and ./backend is added, as described under Backend: resolvers, fetchers, and jobs.
2. Write the widget, config, and form
config.ts is the value contract of the widget. Every field carries .default() so that an empty config reads completely, and the schema silently discards unknown fields (no .strict()) so that an older version reads the config of a newer one. The same rule applies to every later version: fields are only added, with a default, and none disappears or changes its type. The baseline records this (step 6).
.describe('Label|Sentence') carries the label and help text of a field. The form in config-ui.ts reads both with schemaFieldText() from @szenera/widget-editor/config, so a field has the same name everywhere. Which controls the editor knows is determined by the field kind:
Field kind (kind) |
Control in the editor | Value in the config |
|---|---|---|
text, textarea |
one line, several lines | Text |
number, slider |
number, slider | Number |
select, segmented, checkboxGroup |
selection, segmented control, checkboxes | one value, several values from options |
toggle |
switch | yes or no |
color |
color picker | #rrggbb |
date |
calendar | isoDate(): YYYY-MM-DD, empty allowed |
time |
time | timeOfDay(): HH:mm |
dateTime |
calendar, time, and the zone of the entry | isoDateTime(): point in time with offset |
timezone |
search across all zones with German and English names | timezoneName(): the zone’s name (Europe/Berlin), empty for the organization’s zone |
address |
street, postal code, city, and country as one field | postalAddress() |
location |
location of the display or a fixed place from the city search, optionally with a fallback place | placeSelection(): mode, place, fallback |
surface |
surfaces as tiles with a color swatch (options with value, label, swatch) |
the identifier of the surface |
list |
add, remove, duplicate, and reorder entries by handle or arrow keys, collapsed with a short title, a media item in the entry | z.array(…) of texts or small objects |
asset, font |
media library, fonts | identifier of the media item or font |
The form is organized into groups (sections) in the fixed order Template, Source, Content, Appearance, Advanced, and Placement. A group with collapsed: true starts collapsed, so “Advanced” is in nobody’s way. required: true marks a required field, and showWhen with not: true shows a field for every value except the ones named. quickFields names at most two fields of a compact kind (selection, segmented control, switch, slider, color, surface), which the toolbar on the selected widget offers directly. Anyone who creates the description with defineWidgetConfigUI() from @szenera/apps-sdk/config learns at build time if one of these rules is not met.
The settings window builds itself from this description: registerAuthoring registers it with registerConfigUI. A control that no field kind covers (a city search, for example) is added as an individual field (registerConfigField), not as a whole form. How the form connects fields, context, and fit report is covered under Configure widgets.
The value types from @szenera/apps-sdk/config depend only on zod, and the build takes them into the app’s bundle. How the widget draws its surface and sets up placements and views is covered under Widget, configuration, and preview.
3. Write the texts
The texts are in German in the manifest, and every additional language is a block under translations that carries only texts. English is required, and defineAppManifest rejects an incomplete block with the path of the missing text. The same rules apply to every text an organization reads, in the store, in the installation dialog, and in the editor: the formal form of address, no semicolon, at most one dash per text, no words from the platform’s inner workings, and no industry reference in an app for all industries.
Two entries classify the app: category determines the section in the store and in the widget palette, and tier the class (standard in every package, pro from the Pro package, addon booked separately). The app’s manifest test records both so that a shifted classification does not pass silently. A class other than standard additionally needs its entry in the sales list EXPECTED_TIERS in services/api/tests/app-tier-classification-sweep.test.ts, for example 'door-sign': 'pro': it records what is sold, and a test of the API holds every app against it. If the entry is missing, pnpm app:check names it in the Manifest line. An app with an external source or mandatory disclosures additionally carries a legal block (Data access, permissions, and terms).
4. Icon and screenshots
You draw the icon icon.svg yourself. As long as the template’s icon with its data-placeholder marker is there, the Store-Bilder line stays red.
The screenshots are produced with the tool that CI also uses:
node tools/app-screenshots/run.mjs --build --only=door-sign
It captures every view under preview.variants, the main view a second time under the name from thumbnailUrl, and the scene example, which is the main view in a typical scene. If a screenshot needs more, such as an accent color of the environment or a fixed partial config, that goes in package.json under szenera.screenshots.
5. Check with pnpm app:check
pnpm app:check door-sign # from the sources, without a build
pnpm app:check door-sign --built # plus budgets and host commitment against the build
The command shows one line per check, with each finding beneath it. It is the same command locally and in CI, and the check before the build runs pnpm app:check --all across all apps.
| Line | What it checks |
|---|---|
Manifest |
The manifest loads and satisfies the contract. Folder, package, and manifest have the same identifier, package and manifest the same version, every subpath under exports has its source, and the schema is called apps_<id>. A class other than standard is in the sales list (step 3). |
Übersetzungen |
German and English are there. |
Kompatibilität |
Hosts, orientations, and behavior without a network are in the manifest. |
Store-Bilder |
Icon, every view, the main view, and the scene example are there, each a PNG in the dimensions of its orientation and not empty. |
Texte |
Every text from manifest, translation, and form follows the language rule, and every field is named the same in the form as in the schema. |
Formular |
Every form description in src/widgets/<folder>/config-ui.ts keeps the rules that no type expresses (widgetConfigUIIssues): at most two quick fields of a compact kind, every group once and every field in one that exists, every selectable surface once. |
Zustände |
Every widget that fetches data from outside declares the preview state empty ({ id: 'empty', mockData: null } or the empty payload), so that the display shows the quiet surface. An exception until its own wave is listed with a reason in tools/app-kit/src/check/state-exceptions.ts. |
Grundlinie |
Test and baseline are there, the config keeps to it, and the baseline is current: it does not protect a field that is not in it. |
Budgets |
Without a build: the caps are readable. With --built: every bundle under its cap. |
Host-Zusage |
With --built: the runtime bundle imports only names covered by the commitment, and the built manifest contains the range that follows from the imports. |
Verdrahtung |
What has to be right for the app outside the folder. |
Handbuch |
The app has a page in the handbook. |
Notiz |
CHANGELOG.md names the version from package.json with a section ## <version>. The delivery path reads the artifact’s notes from it, and without it, it does not pack the version. The version PR writes the section from the note (step 7), and a new app brings it along from the template. |
A line with – is not checked and does not count as passed: Budgets and Host-Zusage need the build, which the build itself checks anyway. A bundle over its cap breaks vite build, and so does an import outside the commitment.
A fresh app from the template shows three lines red, and each disappears with one small step:
Store-Bilder: an icon of your own and the screenshots from step 4.Verdrahtung:"@szenera-app/<id>": "workspace:*"inservices/public-site/package.jsonand, for an app with a widget, inservices/scene-snapshot/package.json, thenpnpm install. The build of both packages reads the built apps, and only the dependency tells it the order. After that,pnpm codegen:public-app-catalog, which rewrites the public site’s catalog.Handbuch: a pagedocs/de/handbook/apps/<id>.mdof typeapp. The public site builds only if every app has one.
6. Test
pnpm --filter @szenera-app/door-sign typecheck
pnpm --filter @szenera-app/door-sign test
The tests run against the sources, without a build. tests/manifest.test.ts holds contract, category, terms, hosts, and registry, tests/compat.test.ts the baseline and the host range, and the tests under tests/widgets/ the presentation and form. If you change the config, pnpm app-compat --update door-sign writes the new part into the baseline. If the change breaks the rule from step 2, the command writes nothing, and the test names the field.
7. The pull request
The PR carries a note: pnpm changeset names the app, and a note for an app needs the section ## Kunde with the sentence that an organization reads for this version. Without a note, the app gets no version. The PR check runs pnpm app:check --all with the rest of the static checks, and at full depth it builds, captures, packs, and checks every touched app the way the delivery path does. Nothing is signed or stored in the PR.
After the merge, the version PR bumps the version from the note, and the release train sets the tag @szenera-app/<id>@<version>.
8. From the tag into the catalog
For the tag, release-app.yml builds the version exactly once: build, screenshots, the artifact <id>-<version>.szapp, the check, the signature, and storage in the registry. Then the run reports the version to staging and production, and both fetch the same artifact and create the release in the Canary channel. The same version therefore has the same bytes everywhere. What belongs to a release and how the channels work is covered under Signing and the path into the catalog.
9. Review and release in the Ops portal
- Open Apps in the Ops portal, the app’s row, and the Releases tab. The new version is in Canary, and Open review leads to the release review.
- The review shows on the left the store entry as the organizations will see it, on the right the consent sequence, the comparison with the predecessor, “New in this version”, the distribution, and the checklist. If the text for the organizations is missing, enter it here.
- You may use Promote to Beta … on your own. In Beta, the organizations that have signed up for pre-release versions get the version.
- After a few days in Beta: Promote to Stable … with a reason. That creates a request, and the version stays in Beta until then.
- A second person opens the review of the same version and confirms. Only then is the version in Stable, and all organizations whose package includes the app find it in the store.
Check the result
pnpm app:check <id> --builtshows every line with✓.- In the Ops portal, the version is in Stable in the Releases tab, and the history names both people at the release.
- In the store of an organization with a matching package, the app appears under its category, with icon, screenshots, and scene example from the artifact.
If something goes wrong
| What you see | What is behind it and what helps |
|---|---|
app:new creates nothing |
The message names every reason: a taken or reserved identifier, a form outside kebab-case, a name with quotation marks. Fix the reason and repeat the command. |
Manifest line: das Manifest lässt sich nicht laden |
defineAppManifest rejected it, and the text after it names the field, an incomplete translation with its path, for example. |
Manifest line: EXPECTED_TIERS … nennt … nicht |
The app has a class other than standard, and the sales list does not know it. Copy the entry from the finding into services/api/tests/app-tier-classification-sweep.test.ts (step 3). |
Notiz line: CHANGELOG.md nennt die Fassung … nicht |
The version in package.json has been bumped without the version PR having written it. Only the note bumps versions, through the version PR, and package.json and CHANGELOG.md then change together. |
Grundlinie line: nicht aktuell |
The config has a field that the baseline does not know. pnpm app-compat --update <id> records it. |
Grundlinie line: Das neue Feld hat keine Vorgabe |
A new field without .default(). A config of an earlier version does not know it and then no longer reads. |
Store-Bilder line: zu klein |
A template placeholder or an empty image. The screenshot from step 4 replaces it. |
vite build breaks at the cap |
The bundle is larger than szenera.bundleBudgets. First build it slimmer, otherwise raise the cap with a reason in the note field. |
vite build breaks at the host commitment |
The runtime bundle imports a name that the display software does not commit to. Avoid the name or extend the commitment with a new version of the host contract, a change to the platform. |
Overnight, pnpm app:probe proves that this path holds: the probe creates an app from the template, runs its tests and its build, asks seed, screenshots, and budgets whether they see the app, and expects exactly the three red lines of a fresh app from pnpm app:check.