# The manifest

The manifest is the one binding description of an app: `defineAppManifest` in the SDK, the check at load time, and the storage in the catalog all read exactly the same shape. The tables list each field with whether it is required, its format, and its effect. Examples follow below.

> **Type:** reference
> **For:** Developers who write a manifest or read an existing one.
> **Prerequisite:** the `@szenera/apps-sdk` SDK from the platform’s source tree (not publicly released).

## The fields

| Field | Required | Format | Effect |
|---|---|---|---|
| `id` | yes | Lowercase letters with hyphens (`^[a-z][a-z0-9]*(-[a-z0-9]+)*$`). `core` is reserved and rejected. | Canonical identifier of the app. It must match the catalog entry. |
| `version` | yes | Semver (`1.0.0`) | The version that a release freezes. |
| `displayName` | yes | 1–60 characters | Name in the store and in the portal. |
| `description` | yes | max. 300 characters | Short description in the store. |
| `category` | yes | `zeit` · `information` · `web-video` · `gestaltung` · `geschaeft` | The single classification: filter in the store and section in the app area of the scene editor. Widgets do not carry one of their own. |
| `tier` | default `standard` | `standard` · `pro` · `premium` · `addon` | Class: `standard` carries no badge in the store, `pro` carries the **Pro** badge, and `addon` carries the **Add-on** badge and is booked as an add-on. No app carries `premium` today. |
| `vertical` | no | `pharmacy` | Industry facet of the store. Cross-industry apps omit it. |
| `publisher` | no | `{ name: 1–80 characters }` | Publisher shown in the store listing. |
| `schemaName` | yes | `^apps_[a-z][a-z0-9_]+$` | Name of the app database: `apps_<id>`, with the hyphens of the identifier turned into underscores (`qr-code` → `apps_qr_code`). Required even without a database. |
| `apiCompatibility` | yes | `{ appsSdk: semver range, apiVersion?: string, rendererHost?: semver range }` | `appsSdk` applies to the backend: the loader checks the running SDK version against it and does not load the app if it does not match. `rendererHost` names the host contract of the display software that the runtime and authoring bundles need, and it belongs in every manifest with a widget: the display software and the scene editor load a bundle only if their contract version falls within the range. It is set from the SDK’s `RENDERER_HOST_RANGE`, never by hand, so that it moves with the contract. |
| `widgets` | yes | List of widget definitions | The app’s widgets — by the platform’s rule at most one, and none for gate apps. How their `configSchema` comes out in the catalog is covered in [Configure widgets](/apps-sdk/configure-widgets). |
| `resources` | no | List of resource types (`id`, `appId`, `slug`, `name`, `description`, three JSON schemas, `listEndpoint`, `authoringPath`) | Data types that the organization maintains in the portal. `slug` is the address segment of the app area. |
| `permissions` | yes | `{ tenant: [], platform: [] }`, each entry with `key`, `label`, `description`, `defaultRoles` | Permissions that the installation adds to the organization’s permission catalog. `platform` with empty `defaultRoles` is purely operator work. |
| `models` | no | List of model names | Allowlist of the models that the backend may access in the app database. |
| `critical` | default `false` | boolean | For reporting only in the operations diagnostics. Whether a load error aborts startup is decided by the server configuration, not by the manifest. |
| `criticalForTenant` | default `false` | boolean | The portal warns before uninstalling: **This app is part of the core setup**. |
| `signing` | default `optional` | `optional` · `required` | No effect on delivery: The device always checks the signature of every version. The value stays because it is in the manifests of published versions. |
| `preview` | yes | `{ iconUrl }` | The icon for the listing. The store derives the images from the widgets’ variants and serves them from the version’s artifact. It no longer reads the former `screenshots` field, and a new manifest omits it. |
| `store` | no | `tagline` (≤ 140), `longDescription` (≤ 3000), `highlights` (≤ 8), `setupSteps` (≤ 8, optionally with `resourceType`) | Detailed store content. The portal only renders it. |
| `dataAccess` | no | `{ reads[], writes[], external[], retention }` | The disclosure shown before installation. If the block is missing, the most restrictive reading applies. |
| `unlocks` | no, max. 8 | Keys in lowercase with hyphens, ≤ 40 characters | Portal features that the installation unlocks. Every key must appear in the platform’s catalog. |
| `connects` | no, max. 4 | Keys in lowercase with hyphens, ≤ 40 characters | Storage providers whose connection management the app brings onto its own page. |
| `legal` | no | see below | Terms that trigger consent and a notice in the widget. |
| `compatibility` | yes (in the SDK) | `{ hosts, orientations, offline }`, see below | Where the app runs. The store shows it under “Runs on”, and assigning the app to a display warns if the display’s host is missing. Optional in the format so that manifests of earlier versions stay readable. |
| `translations` | no (for the bundled apps: `en-US` required) | one block of texts per language, see below | The texts in further languages. Without a block, the store shows German. |

`unlocks` and `connects` are **omitted rather than set empty**: an empty field would shift the canonical hash of every existing version although nothing has changed.

## The compatibility block

`defineAppManifest` requires the block both in the type and at runtime. Without it, the function throws with a sentence that names the three fields. Each list names a value at most once.

| Field | Values | Meaning |
|---|---|---|
| `hosts` | `szenera-os` · `fire-tv` · `tizen` · `webos` · `web-display`, at least one | The hosts the app runs on: SzeneraOS on the platform’s own device, the app for Fire TV and Android, the apps for Samsung Tizen and LG webOS, and a browser without a host of its own. |
| `orientations` | `landscape` · `portrait`, at least one | The orientations of the display, landscape and portrait, for which the app is suited. |
| `offline` | `full` · `last-known` · `none` | What the app shows without a network. `full` keeps showing everything because the app renders solely from its configuration and the device’s local stock (“static” in the store). `last-known` shows the state that the server last put into the display’s snapshot. `none` shows nothing because the app loads live from the network. |

## The translations block

The texts of the manifest are written in German, the required language. Each additional language
lives under `translations` as its own block, which carries texts only: `displayName` and
`description`, `store` (tagline, long text, features, and setup steps in the order of the German
ones), `widgets` by identifier with name, description, and `variants` by view, `resources` by
identifier, `permissions` by key, `dataAccess.external` in the order of the services, and
`legal.disclaimerSummary`. Identifiers, values, and rules never appear in a block.

A block is either complete or absent: `defineAppManifest` checks it with
`appManifestTranslationIssues()` and throws at build time, naming the location. The store answers
in the language that the portal requests and falls back to German. English terms follow the portals:
`display`, never `screen`, and the video wall is called `video wall`. `pnpm app:check <id>` checks
the texts of both languages against the house rule, in the `Texte` line.

## The legal block

| Field | Required | Meaning |
|---|---|---|
| `disclaimerKey` | yes | Key of the version that a consent refers to (`<group>.bedingungen.v1`). A change to the substance of the notice bumps it (`v1` → `v2`). |
| `disclaimerSummary` | yes, max. 500 characters | Short summary in plain text. The installation dialog, the store page, and the widget footer show it unchanged. It represents the version, and rewording it under the same key does not require consent. |
| `termsPath` | no | Path of the full text on the public website (`^\/[a-z0-9-]+(\/[a-z0-9-]+)*$`, ≤ 200 characters, e.g., `/app-bedingungen/notdienst`). A path, not an address — the portal prepends its base. |
| `requireAcceptanceAt` | default `install` | `install`: consent at installation and afterward with every new version of the notice. `upgrade`: consent with every new version of the app, even if the notice is unchanged. `never`: no consent. |

A new `disclaimerKey` makes a release subject to consent: as soon as the new version is published as `stable`, the installation moves to **Update waiting**. Anyone who changes the substance of the notice therefore bumps the key, in the same pass as the short summary, the full text, and the app version. The platform silently adopts a reworded short summary under the same key. A manifest change without a version bump does not reach existing installations.

## What is not in the manifest

- **Fetchers and jobs.** An app declares them as decorators on classes in its backend (`@Fetcher`, `@JobHandler`, `@WidgetResolver`), and the platform discovers them at startup. The manifest remains a pure data format.
- **Whether a load error aborts startup.** That is a property of the installation and lives in the server configuration of the operations side. `critical` in the manifest is only a report.

## Example: the manifest of the Pharmacies on duty app (abridged)

```ts
import { defineAppManifest, RENDERER_HOST_RANGE } from '@szenera/apps-sdk/manifest';

export const manifest = defineAppManifest({
  id: 'pharmacies-on-duty',
  version: '1.3.0',
  displayName: 'Notdienst',
  description:
    'Zeigt die Apotheken im Notdienst rund um Ihren Standort, regelmäßig aktualisiert für ganz Deutschland.',
  tier: 'pro',
  category: 'geschaeft',
  vertical: 'pharmacy',
  schemaName: 'apps_pharmacies_on_duty',
  apiCompatibility: { appsSdk: '^0.2.0', rendererHost: RENDERER_HOST_RANGE },
  widgets: [PharmaciesOnDutyDisplayWidget],
  resources: [
    { ...PharmaciesOnDutyLocationResource, slug: 'locations' },
    { ...PharmaciesOnDutyScheduleResource, slug: 'schedules' },
  ],
  permissions: {
    tenant: [
      {
        key: 'pharmacies-on-duty.location.read',
        label: 'Standorte lesen',
        description: 'Hinterlegte Apotheken-Standorte ansehen, für die Notdienstpläne geladen werden.',
        defaultRoles: ['OWNER', 'MANAGER', 'MEMBER'],
      },
      // … further permissions for the team …
    ],
    platform: [
      {
        key: 'pharmacies-on-duty.crawler.config',
        label: 'Crawler konfigurieren',
        description: 'Betreiber-Recht: Quellen, Abrufintervalle und Parser des Notdienst-Crawlers einstellen.',
        defaultRoles: [],
      },
    ],
  },
  publisher: { name: 'Szenera' },
  signing: 'required',
  legal: {
    disclaimerKey: 'notdienst.bedingungen.v1',
    disclaimerSummary: 'Notdienstangaben stammen aus öffentlichen Verzeichnissen … und werden ohne Gewähr angezeigt …',
    termsPath: '/app-bedingungen/notdienst',
    requireAcceptanceAt: 'install',
  },
  preview: { iconUrl: '/apps/pharmacies-on-duty/icon.svg' },
  dataAccess: {
    external: [
      {
        name: 'Öffentliche Notdienst-Verzeichnisse (u. a. aponet.de)',
        purpose: 'Wir rufen die Dienstpläne für Ihre Standorte ab und halten sie aktuell.',
        sends: 'Postleitzahl und Umkreis der Standorte, die Sie angelegt haben.',
      },
    ],
  },
  // The server fetches the duty schedules, and the display keeps the last state in its snapshot.
  compatibility: {
    hosts: ['szenera-os', 'fire-tv', 'tizen', 'webos', 'web-display'],
    orientations: ['landscape', 'portrait'],
    offline: 'last-known',
  },
});
```

## Form validation and cross-field rules

`defineAppManifest` validates the shape against the schema, applies default values (`tier`, `critical`, `criticalForTenant`, `signing`), requires the `compatibility` block, and throws on every shape error. Three rules that arise from several fields at once are checked only by the platform at load time: the `appId` of each widget must equal the `id` of the manifest, each resource must carry the app’s prefix, and no two widgets may share an identifier. A manifest that contradicts the schema is rejected by release creation with `Manifest.ValidationFailed` (HTTP 422).

## Related

- [Widget, configuration, and preview](/apps-sdk/widget-and-preview) — the widget definition in the `widgets` field.
- [Data access, permissions, and terms](/apps-sdk/data-access) — `dataAccess`, `permissions`, `unlocks`, `connects`, and `legal` in detail.
- [Signing and the path into the catalog](/apps-sdk/signing-and-catalog) — what follows from `signing` at release and on the device.
