Skip to content

The manifest contract

manifest.json is the one contract in My Own Suite that is a promise to people outside the project: package authors. This page is the authoritative reference for manifest generation 1 — the first locked generation. It is written for both humans and AI agents authoring packages.

The promise, concretely:

  1. Every field documented here is supported by every MOS release that supports generation 1. A valid generation-1 manifest will not be rejected by a future MOS release.
  2. Unknown fields are ignored, never fatal. A manifest may carry fields this page does not describe; MOS validates what it knows, ignores the rest, and never projects an unknown field into a runtime. This is the amendment mechanism: future capabilities arrive as optional additions, and a package that uses one declares the minimumMosVersion that introduced it.
  3. Nothing beyond the required core is ever mandatory. UI renders an absent optional field as absent, not as an error.
  4. Amendments are additive and rare. Changing the meaning of an existing field requires a new generation (manifestVersion: 2), which is an event, not maintenance.

Two artifacts define the contract:

  • apps/manifest.schema.json — the machine-readable JSON Schema (draft 2020-12). Validate against it with any standard JSON Schema tool. MOS itself interprets this exact file; there is no second hand-written validator to drift from it.
  • The semantic rules on this page — cross-references and the template grammar, which JSON Schema cannot express. In a repository checkout, npm run apps:manifest:check runs both passes over every package (or over folders you name) without running MOS.
{
"manifestVersion": 1,
"id": "example-app",
"name": "Example App",
"version": "0.1.0",
"minimumMosVersion": "0.17.0",
"summary": "One plain-language line about what the app does.",
"category": "tools",
"icon": "icon.png",
"resources": {
"services": {
"example-app": {
"dockerfile": "Dockerfile",
"internalPort": 8080,
"env": { "PUBLIC_URL": "${app.publicUrl}" },
"volumes": ["data:/data"]
}
}
},
"routes": [{ "host": "example-app", "service": "example-app" }],
"health": { "type": "http", "url": "http://example-app:8080/healthz" }
}

That is a working package once the folder also contains the pinned Dockerfile, an icon.png, and (for the official catalog) a privacy-review.json.

FieldMeaning
manifestVersionAlways 1. Declares the generation this manifest targets, so a MOS release that does not know it refuses the package instead of misreading it.
idDNS-safe lowercase package id (^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$), stable for the life of the package. Names the folder, namespaces containers and volumes.
nameHuman-readable app name.
versionThe package version (semver). Independent of both the MOS platform version and the upstream app version. Any content change requires a bump — installed machines are offered updates purely by comparing this value.
minimumMosVersionOldest MOS release the package works on. Raise it when you use a field or template namespace introduced later.
summaryOne-line catalog-card description.
categoryFree text. Reuse an existing category when one fits (media, storage, office, security, tools); an unknown category renders as written with default styling.
resources.servicesAt least one service — see below.
healthHow MOS decides the app is up — see below.

Each key is a DNS-safe service id, which is also the container’s hostname on the package-private network.

FieldRequiredMeaning
dockerfileyesPackage-root Dockerfile: Dockerfile for the primary service, Dockerfile.<service> for others. Base images must be pinned by immutable digest (FROM image@sha256:…) — floating tags are refused at review.
internalPortyesThe one TCP port the service listens on.
envnoEnvironment map: UPPER_CASE keys, string values, template references allowed (see the grammar below).
volumesnoPersistent storage as <volume-name>:<absolute-container-path>. Named volumes only — host paths, bind mounts, and device paths are refused by design (they break backup, restore, and isolation). Volume names are unique within the package; each volume belongs to exactly one service.

MOS does not order service startup. A service must tolerate its dependencies starting later and retry — every mainstream server image already does.

"routes": [{ "host": "example-app", "service": "example-app" }]

Each route publishes one HTTPS hostname (<host>.<suite-domain>), terminated by MOS and reverse-proxied to the service’s internalPort. Routes are structured data — raw proxy configuration is refused. Generation 1 routes are HTTP(S) only; a future contract for other protocols would arrive as a separate optional field, not a reinterpretation of routes.

"health": { "type": "http", "url": "http://example-app:8080/healthz" }

type is http in generation 1. The URL’s hostname must be a declared service id — the probe runs on the package network. Future probe types (tcp, …) arrive as new type values gated by minimumMosVersion.

The install form, as data. Each field:

FieldMeaning
idcamelCase id, referenced as ${config.<id>} (non-secret) or ${secret.<id>} (secret).
typetext, email, password, url, or boolean. Values are stored as strings; boolean stores "true" / "false".
labelWhat the owner sees.
requiredOptional boolean.
secretSecret values are stored as redacted references and materialized only for runtime apply. A secret field must not declare a default.
redactedLabelLabel shown in place of a secret’s value.
defaultPrefill for non-secret fields. The only place ${owner.name} / ${owner.email} may appear.
generated{ "kind": "random", "bytes": 16–128, "encoding": "base64url" | "hex" } — MOS generates the value at install; the owner is never prompted.

All display-only. description, tags, related (app ids), features ({title, body?}), resourceHint (level: low/medium/high + label/description), privacy (summary, notes[] — the plain-language summary; the bound privacy-review.json is the authoritative assessment), links (website/docs/repository; other keys ignored), demoDeployTargets (public-site deploy links), and:

  • replaces — an array of product names, one per entry, ranked most-recognised first: ["Google Photos", "iCloud Photos", "Amazon Photos", "Flickr"]. List every commercial product the app genuinely stands in for, not only the obvious two — search matches each entry, and the app’s page on this site lists all of them. Space-constrained surfaces (catalog cards, the Suite Manager detail hero) name only the first two, which is why the ranking matters.
  • screenshots{src, alt?, caption?} where src is a package-relative path listed in packageFiles. Remote screenshot URLs are refused: browsing the catalog must never fetch third-party origins.

group and icon are required when the block is present; name defaults to the package name and description to the summary. Omit the block for packages that should not appear on the dashboard (capability providers usually do).

Declarative post-install guidance rendered by Suite Manager: title, summary, and sections[], each {id, type, title, body?, …} with type one of note, warning, steps (with steps: [string]), values (copyable {label, value, copy?} entries), choice-guide (per-device choices[], each with its own steps), manual-complete (with actionLabel).

Guides are data, never behavior: no scripts, no app-specific components, no host mutations. values[].value may interpolate ${app.publicUrl} and non-secret ${config.*}; secrets never appear in a guide — write “use the password you chose during install” instead.

What updating to this package version means for an installed machine: backupRequired, breakingChanges (declared structural areas — an undeclared structural change makes the update unofferable), downtime (none/brief/extended/unknown), migrations[], ownerActions[], minimumAppAgentVersion, rollback (safe/not-guaranteed/unsupported).

  • architectures["amd64"] and/or ["arm64"]. Omitted means unconstrained; incompatible hosts refuse the install before building.
  • packageFiles — every extra file the package ships beyond the fixed root set (manifest.json, Dockerfile*, README.md, entrypoint.sh, icon.*, privacy-review.json). Undeclared files do not survive packaging.
  • icon — package-relative icon path, conventionally icon.png.

Manifest strings may reference values MOS resolves at install or runtime:

${namespace.path}

A reference is recognized only when the namespace is a lowercase word followed by a dot. Anything else — ${UPPER_CASE}, ${no-dot}, $plain — is literal text and passes through untouched, so shell-style ${VAR} syntax in env values keeps working.

NamespaceResolves toAllowed in
${config.<fieldId>}A non-secret setup field’s valueService env, onboarding values[].value, provisional areas
${secret.<fieldId>}A secret setup field’s valueService env and provisional areas only — never onboarding, never catalog
${app.host} / ${app.scheme} / ${app.publicUrl}The app’s public hostname, scheme, and full URLService env, onboarding values[].value, provisional areas
${owner.name} / ${owner.email}The suite owner’s profilesetup.fields[].default only
${import.*} / ${export.*}Capability wiringThe provisional capability system only

Every reference is validated. A typo like ${config.adminUserName} fails validation instead of shipping verbatim into a container env var and failing silently on someone else’s machine. An unknown namespace is an error too — future namespaces (an SMTP relay would introduce ${smtp.*}) are reserved and arrive gated by minimumMosVersion.

These work today for official packages but their shape is not frozen; avoid them in external packages unless you accept migration later:

  • role (capability-provider), exports, integrations, configTargets, usefulness — the capability system. Proven by exactly one relationship (Seafile ⇄ ONLYOFFICE); it will be locked when more relationships have shaped it.
  • homepage.widget — only the monthly calendar widget exists; a general widget contract is future work.
  • routes[].internalIcalBridge — a token-gated read-only proxy path, expected to be replaced by a general bridge contract.

These are refused by design, not omissions to work around: host paths and bind mounts, cross-package shared volumes, host networking, device passthrough, privileged containers, the Docker socket, raw Caddy/proxy directives, arbitrary scripts in guides, and OIDC/LDAP/SSO wiring (MOS is deliberately single-owner). If your app cannot be expressed without one of these, that is a platform conversation — open an issue rather than bending the package.

Terminal window
# both passes (structure + semantics), no running MOS required:
npm run apps:manifest:check # every apps/<app>/
npm run apps:manifest:check -- path/to/pkg # specific folder(s)

Or validate structure alone against apps/manifest.schema.json with any JSON Schema validator. Semantic rules the schema cannot express (and the checker enforces): template references resolve against declared fields and namespaces; routes[].service and the health hostname name declared services; declared package files exist; screenshots are declared in packageFiles; secret fields carry no defaults; volume names are unique per package.

Recorded in the repository’s decision log and agent rules: a manifest change must be an optional, additive field; the UI must render its absence as absence; a change to an existing field’s meaning requires a new generation; and reintroducing a closed allow-list anywhere in the manifest shape is a regression (a unit test fails if anyone tries).