# From template to release

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.

> **Type:** guide
> **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 install` and `pnpm build`, Node 24. For the release, two operator accounts with the permissions `platform.app-catalog.import` and `platform.app-catalog.promote`.

## The path in one picture

```text
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

```bash
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`, not `tuerschild`). It applies to the folder, the package `@szenera-app/<id>`, the manifest, the template’s one widget, and the database schema `apps_<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 install`** adds the app to the workspace. Afterward the lockfile is in compact form: `pnpm exec prettier --write pnpm-lock.yaml` brings it into the tree’s format, and `pnpm lizenzhinweise` writes the third-party notices with the new state of the lockfile.
- **`pnpm --filter @szenera-app/<id> build`** builds 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](/apps-sdk/backend).

## 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](/apps-sdk/configure-widgets#the-form-in-the-editor).

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](/apps-sdk/widget-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](/apps-sdk/data-access)).

## 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:

```bash
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`

```bash
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:*"` in `services/public-site/package.json` and, for an app with a widget, in `services/scene-snapshot/package.json`, then `pnpm 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 page `docs/de/handbook/apps/<id>.md` of type `app`. The public site builds only if every app has one.

## 6. Test

```bash
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](/apps-sdk/signing-and-catalog).

## 9. Review and release in the Ops portal

1. 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.
2. 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.
3. You may use **Promote to Beta …** on your own. In **Beta**, the organizations that have signed up for pre-release versions get the version.
4. After a few days in Beta: **Promote to Stable …** with a reason. That creates a request, and the version stays in Beta until then.
5. 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> --built` shows 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`.
