Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Manifest: root fields

manifest.json sits at the root of your project, next to package.json. It is the only file the platform reads to learn that your plugin exists, what it ships, and how to serve it. It travels in both zips, is cached by the platform, and is handed to the renderer on every server render.

There are two shapes. A manifest is the suite (multi-widget) root if and only if widgets is a non-empty array; otherwise it is the single-widget (v1) shape. Nothing else distinguishes them — there is no version flag and no type field. See The two manifest shapes for how to choose, and Manifest: widget summary for every field inside a widgets[] entry.

This page is normative for every ROOT-level field. Per-widget fields live in widget-summary.md; layout semantics in layouts.md; every hard number and the full error index in limits-and-errors.md.

Where root fields are enforced

Three checkpoints read your root manifest. This page tags each rule with the one that catches it, because they happen days apart.

TagCheckpointWho sees it
publish ERROR (audit)The dashboard audits your source zip in your browser, before a byte is uploaded.You, immediately.
publish ERROR (submit)POST /api/v3/plugins/submissions re-validates the manifest server-side.You, as a 422 on Upload.
publish ERROR (build)The built-zip audit plus central’s publish/rollback gates, run by the reviewer after approval.The reviewer. You learn only that publishing failed.

publish warning never blocks anything; warnings raised on the built zip are visible only to the reviewer. Enforcement-level vocabulary is defined once in limits-and-errors.md.

Always required (both shapes)

FieldTypeRequiredRuleOn violation
idstringBothThe plugin slug. /^[a-z0-9][a-z0-9-]*$/, ≤ 255 chars. Used verbatim — it is not slugified for you.publish ERROR (audit): manifest "id" ("My_Plugin") must be lowercase alphanumeric with hyphens — it is the plugin's slug. · missing → manifest.json is missing the required "id" field.
versionstringBothThree-part MAJOR.MINOR.PATCH, ≤ 64 chars. Strictly greater than your published version.publish ERROR (audit): manifest "version" ("1.2") is not valid semver (e.g. 1.2.0). · publish ERROR (submit): 422 INVALID_VERSION, 422 VERSION_NOT_GREATER
namestringBothNon-empty, ≤ 255 chars. Display name in the marketplace and the widget picker.publish ERROR (audit): manifest.json is missing the required "name" field.
runtime.reactstringBothA semver range, e.g. "^19.0.0". Required by the PluginManifest type, and every real manifest carries it — but it is declarative metadata only (see below).advisory (unvalidated): no checkpoint reads or requires it. Ship it anyway; a reviewer looks at it.

Required in the single-widget shape only

Present these three when widgets is absent or empty. A suite root must not carry them.

FieldTypeRequiredRuleOn violation
widgetTypestringSingle-widgetNon-empty string. Its value is never read by anything — routing uses plugin:{id}. The scaffold sets it to your id; keep doing that.publish ERROR (audit): manifest.json is missing the required "widgetType" field.
ssr.urlstringSingle-widgetPath to the SSR bundle, e.g. dist/my-widget-ssr.umd.js. A v1 plugin has no CSR-only mode — there is no ssr: false at the root.publish ERROR (audit): manifest.json is missing "ssr.url" (the SSR bundle path). · at publish: manifest is missing "ssr.url".
assets.jsstringSingle-widgetPath to the client IIFE. A v1 manifest with no assets.js resolves to null and the widget never renders.publish ERROR (audit): manifest.json is missing "assets.js" (the client bundle path). · at publish: manifest is missing "assets.js".
assets.cssstringNoOptional in the type and in both audits.
assets.fontsstring[]NoMax 8; absolute http(s) URLs or paths confined to the version prefix. (widget-core 0.12.0+) Honoured at resolve for v1 roots and validated by central, but not by the browser audit. See assets-and-urls.md.publish ERROR (submit) only
configSchemaJSON SchemaNov1 only. Generated by your build from zodToManifestSchema(configZod) — never hand-edited. In a suite these move to per-widget sub-manifests.publish warning (build): manifest has no configSchema — was this zipped after `npm run build`? The dashboard form will be empty.
uiSchemaobjectNov1 only, same as above. See config-and-ui-schema.md.

Two more root fields are read for v1 manifests but not declared in the PluginManifest TypeScript type, so your editor will not offer them: root-level mediaSafeAuto (strict true) and root-level externalFetch (string[]). Both resolve exactly like their widgets[] counterparts and neither is validated at publish. In a suite, declare them per widget.

Not supported yet. A v1 root has no category, no slots, no pages and no expectedHeight. A single-widget plugin can never be a layout and can never be CSR-only. Both need the suite shape.

Optional everywhere

FieldTypeRequiredRuleOn violation
descriptionstringNoOptional. Shown on the marketplace card. String, ≤ 4000 chars if present.publish ERROR (submit): 422 INVALID_MANIFEST Manifest "description" must be a string of at most 4000 characters.
author{name, url?}NoBoth members are strings ≤ 255 chars. author.url renders only when it matches ^https?://.publish ERROR (submit): Manifest "author.name" must be a string of at most 255 characters.
homepagestringNoRendered on the plugin page only when it matches /^https?:\/\//i. Anything else is silently dropped.advisory (unvalidated)
supportstringNoSame rule as homepage.advisory (unvalidated)
docsstringNoSame rule as homepage.advisory (unvalidated)
licensestringNoFree text (e.g. "MIT"). Nothing parses it.advisory (unvalidated)
tagsstring[]NoNon-string entries are dropped; the list is truncated to 12 when displayed.silent runtime truncation
screenshotsstring[]Nomedia/<file> paths only — an absolute URL is rejected. Each file must ship in the built zip. No count cap.publish ERROR (submit + build), see Media references
iconstringNomedia/<file> (flat) or an absolute http(s) URL, ≤ 2048 chars. (widget-core 0.12.0+)publish ERROR (submit + build)
coverstringNoSame value rules as icon. (widget-core 0.12.0+)publish ERROR (submit + build)
readmestringNomedia/<file>.md or media/<dir>/<file>.md, ≤ 255 chars, file ≤ 64 KB. (widget-core 0.12.1+)publish ERROR (submit + build)
widgetsPluginWidgetSummary[]NoPresence flips the manifest to the suite shape. Max 24 entries. (widget-core 0.11.0+) See widget-summary.md.publish ERROR (audit): manifest declares 26 widgets — the limit is 24 per plugin.
presetsPreset[]NoMax 8. Suite shape only. (widget-core 0.11.0+) See Presets.publish ERROR (audit + submit)
shadowDOMbooleanNoWhole-plugin flag. (widget-core 0.10.1+) See shadowDOM.advisory (unvalidated), but see the stranding failure below
runtime.widgetCorestringNo in the type, required to publishStamped by the build. Never hand-write it. Must be ≥ 0.10.0.publish ERROR (build), see runtime

A media/CHANGELOG.md in your zip becomes the plugin page’s Changelog tab by convention — it is not declared anywhere in the manifest.

Not supported yet. There is no root-level keywords, minPlatformVersion, permissions or dependencies field. Unknown root keys are ignored everywhere, so a typo like "screenshot" fails silently rather than erroring.

id

manifest.id is the plugin slug. Central stores it verbatim after checking /^[a-z0-9][a-z0-9-]*$/; there is no Str::slug normalisation, so My_Plugin is rejected rather than repaired.

The slug is permanent and globally unique across all authors:

  • An existing row owned by someone else → 409 SLUG_TAKEN, The plugin id "acme-suite" already belongs to another author.
  • An existing row with no owner (a legacy admin-registered plugin) → 409 SLUG_RESERVED, The plugin id "acme-suite" is reserved. Contact an administrator to claim it.

It also constrains your upload key: the submission object must live under submissions/{id}/…, else 422 INVALID_ZIP_KEY. Full submit flow in ship/preflight-and-submit.md.

Your id is the first half of every keyword the platform stores against a customer’s widget row (plugin:{id} or plugin:{id}:{widget}), which is why it can never change.

version

Three hard rules, in the order you will hit them:

  1. Shape, in the browser. /^\d+\.\d+\.\d+(-…)?(\+…)?$/. 1.2 fails with manifest "version" ("1.2") is not valid semver (e.g. 1.2.0).
  2. Three parts, at central. Composer’s parser would accept 1.0; central re-checks with its own regex and returns 422 INVALID_VERSION, Manifest "version" must be MAJOR.MINOR.PATCH semver: 1.0.
  3. Strictly greater, at central. 422 VERSION_NOT_GREATER, Version 1.2.0 must be greater than the published 1.3.0.

One version number covers the whole suite. There is no per-widget version.

Published version prefixes on the CDN are immutable — re-publishing the same version is refused. See ship/publish-versions-and-rollback.md.

Not supported yet. Pre-release suffixes (1.0.0-rc.1) pass every validator, but each immutability detector in the fleet requires a bare X.Y.Z path segment. A pre-release publish silently loses direct content-addressed asset URLs, the life-of-instance build-manifest cache and the slug-keyed SSR bundle cache, falling back to short-TTL proxied serving everywhere. Ship plain X.Y.Z versions.

runtime

// packages/create-hr-plugin/template/manifest.json — the build adds `widgetCore`
{
  "runtime": { "react": "^19.0.0" }
}

After a build:

// /Users/.../hr-plugins/pdp-suite/manifest.json (real, published 1.3.3)
{
  "runtime": { "react": "^19.0.0", "widgetCore": "0.12.0" }
}

runtime.react is declarative metadata. Nothing in the renderer, the dashboard, the asset proxy or central reads its value, and no checkpoint requires it to be present — the build’s stamp pass creates the runtime object for you if it is missing. It documents intent for a reviewer; it does not gate anything. (The old guide claimed “the renderer rejects plugins whose runtime.react doesn’t match”. There is no such code.)

What actually controls which React you run against is the exact pin set in your package.json, because the platform externalises React and hands you its own copy at runtime. Patch drift trips react-dom’s own Incompatible React versions assertion. The canonical pins live in get-started/01-install-and-versions.md.

runtime.widgetCore is the enforced gate. It records the exact @homerunner-next/widget-core version your build ran with, and it is written for you by the build’s finalize pass — treat it as generated output, not a field you own. Expect manifest.json to come back dirty in git after npm run build; commit it.

RuleEnforcementMessage
Present and semverpublish ERROR (build)manifest has no "runtime.widgetCore" stamp — this build predates the evergreen runtime. Update @homerunner-next/widget-core to 0.10.0+ and rebuild.
0.10.0publish ERROR (build)Built against widget-core 0.9.0 — the fleet requires 0.10.0+ (older builds bundle a stale mount that breaks on SSR pages). Update and rebuild.
Bare X.Y.Z at centralpublish ERROR (build)422 MANIFEST_WIDGET_CORE_MISSING — central’s regex is /^\d+\.\d+\.\d+$/, so a pre-release stamp is rejected even though the dashboard audit accepts one.

The same gate runs on rollback, so you cannot roll back to a version built with a pre-0.10.0 SDK.

shadowDOM

// /Users/.../hr-plugins/pdp-suite/manifest.json
{ "shadowDOM": true }

A root-level flag governing the whole plugin — there is no per-widget override. Omit it (or set true) unless every one of your client bootstraps passes shadowDOM: false to mount().

It buys one thing: Declarative Shadow DOM for your server-rendered markup, which means your widget is styled at parse time with no mount blink. The renderer grants DSD only when both hold:

  • manifest.shadowDOM !== false, and
  • runtime.widgetCore parses to ≥ 0.10.0.

Nothing validates that the flag agrees with your bootstrap. When they disagree — the manifest says shadow, mount() says none — the server attaches a shadow root that your mount never adopts, and the SSR content is stranded: visible in view-source, invisible on screen. See runtime-and-mount.md for the mount side and recipes/styling-and-theming.md for the styling consequences.

Media references

Four fields point at files under media/. Their value rules are not the same, and the differences are load-bearing.

FieldAccepts a media/ pathAccepts an absolute URLNestingLength cap
iconyes, flat media/<file>yesnone≤ 2048 chars
coveryes, flat media/<file>yesnone≤ 2048 chars
widgets[].iconyes, flat media/<file>yesnone≤ 2048 chars
screenshots[]yes, flat media/<file>no — rejectednone
readme, widgets[].readmeyes, .md onlynoone directory level≤ 255 chars path, ≤ 64 KB file

Exact patterns, from the SDK:

// packages/homerunner-widget-core/src/manifest.ts (isPluginMediaRef, isPluginReadmeRef)
/^media\/[^/\\]+$/            // icon, cover, widgets[].icon — flat, no subdirectory
/^https?:\/\/[^\s]+$/i        // …or an absolute URL, for those three only
/^media\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)?\.md$/   // readme — one optional subdir

Violations:

ConditionEnforcementMessage
icon/cover is neither shapepublish ERROR (build)manifest "icon" must be a "media/<file>" path or an absolute http(s) URL.
A declared media/ icon is absent from the zippublish ERROR (build)Declared icon media/logo.svg is missing from the zip.
A screenshot is not media/<file>publish ERROR (build)manifest screenshot "https://cdn.example.com/1.png" must be a "media/<file>" path.
A declared screenshot is absentpublish ERROR (build)Declared screenshot media/1.png is missing from the zip.
readme is the wrong shapepublish ERROR (build)manifest "readme" must be a "media/<file>.md" (or "media/<dir>/<file>.md") path.
A declared README is absentpublish ERROR (build)Declared manifest "readme" media/README.md is missing from the zip.
A README exceeds 64 KBpublish ERROR (build)manifest "readme" media/README.md is 71 KB — the limit is 64 KB.

Central re-checks the same shapes at submit and returns 422 INVALID_MANIFEST with its own wording, e.g. Manifest "icon" must be a "media/<file>" path or an absolute http(s) URL (max 2048 characters).

Three consequences worth internalising:

  • media/ is authored content. Nothing generates it, and the reviewer only runs your build. A declared icon, screenshot or README that is not committed to your source zip makes the publish fail after you were told the submission looked fine. See packaging-and-publishing.md.
  • A media/ reference resolves only after your first publish. It is resolved with new URL(ref, manifest_url), and an unpublished plugin has no manifest_url — so the resolver returns null and the icon falls back to a glyph. An absolute URL renders immediately, including in the review console. If you want your icon visible during first review, use a URL; if you want it version-immutable, use media/.
  • Screenshots asymmetry is deliberate. Screenshot files publish under the same immutable version prefix as your bundles, so they may not point off-platform. The plugin page additionally drops any screenshot that is not media/…, and renders none at all before the first publish.

READMEs are rendered through a markdown sanitizer — no raw HTML, no scripts.

Presets

// /Users/.../hr-plugins/pdp-suite/manifest.json (root level, beside "widgets")
"presets": [
  {
    "name": "PDP Starter",
    "layout": "pdp-frame",
    "slots": {
      "hero":    ["stay-hero", "property-title", "gallery"],
      "main":    ["stay-facts", "description", "amenities", "calendar"],
      "sidebar": ["booking", "booking-cta"],
      "bottom":  ["reviews"]
    }
  }
]

presets?: Array<{ name: string; layout: string; slots: Record<string, string[]> }> — a root field, suite shape only (widget-core 0.11.0+). Presets on a v1 manifest are neither validated nor used, because a preset must reference a declared layout widget and only suite roots declare widgets.

Referential integrity is checked at publish, in both the browser audit and central:

RuleEnforcementMessage (browser audit)
At most 8 entries, and a listpublish ERROR (audit)manifest "presets" must be a list of at most 8 entries.
Every preset has a namepublish ERROR (audit)each manifest preset needs a "name".
layout names a declared widget with category: "layout"publish ERROR (audit)preset "PDP Starter" must reference a declared layout widget.
slots is an object of arrays of stringspublish ERROR (audit)preset "PDP Starter" needs a "slots" object. / preset "PDP Starter" has an invalid slot binding shape.
Every slot child is one of this plugin’s own non-layout widget slugs, or a bare system widget keyword /^[a-z][a-z0-9-]{0,63}$/publish ERROR (audit)preset "PDP Starter" references "plugin:acme:hero" which is neither a declared content widget nor a system widget keyword.

A plugin: reference is never accepted as a slot child — name your own widget by its bare slug, and a system widget by its bare keyword.

Not supported yet. Preset slot keys are never checked against the layout’s declared slot names. A typo ("sidebar" vs "side-bar") passes every validator and creates a binding the layout never renders. Copy the slot names from your own slots[] and diff them by eye.

What one click on a preset actually creates — which children are created versus reused, the naming, the rollback on failure — is in layouts.md and recipes/composing-a-page.md.

Suite roots and v1 roots are not mixable

Mutual exclusivity is convention, not enforcement. If a manifest carries both widgets[] and the v1 root fields, resolution takes the multi branch, silently ignores root widgetType / ssr / assets / configSchema, and the bare plugin:{id} keyword stops resolving — every existing v1 placement breaks. Conversely, a widget slug resolved against a v1 manifest returns null.

Both failures are fail-closed: the widget is removed from the page and the renderer records the reason keyword does not resolve against the plugin manifest. The resolution table and the failure node it produces are in concepts/manifest-shapes.md.

The shape is frozen at first publish. Central refuses a manifest that flips shape with 422 MANIFEST_SHAPE_CHANGED:

This manifest changes the plugin between single-widget and multi-widget shapes. Existing
widget placements and embed URLs would stop resolving — publish the new shape under a new
plugin slug instead.

Note when this fires: submit and review do not check the shape. The gate runs at publish and at rollback only, and the dashboard mirrors it just before the reviewer uploads your built zip with:

This build changes {slug} between single-widget and multi-widget shapes. Existing
placements and embed URLs would stop resolving — publish the new shape under a new plugin
slug.

So a shape flip will pass your upload and pass review, and die at publish. The only remedy is a new plugin slug. If you might ever ship a second widget, start as a suite — a suite of one is legal.

How the manifest reaches the renderer

  1. At publish, central stores your root manifest in manifest_cache and records manifest_url (the CDN URL of that exact manifest.json) and current_version.
  2. The renderer asks central for a feed’s widgets. For every widget row whose keyword starts with plugin:, the join attaches two fields:
// what the renderer receives per widget row (suite keyword form)
{
  "id": "…",
  "keyword": "plugin:pdp-suite:stay-hero",
  "plugin_manifest": { /* your ROOT manifest.json, verbatim */ },
  "plugin_manifest_url": "https://plugins.homerunner.io/prod/pdp-suite/1.3.3/manifest.json"
}

The v1 keyword form is plugin:{id} with no widget segment.

  1. The renderer parses the keyword, resolves it against plugin_manifest, and reads the resolved widget’s ssr.url and assets.*.

Four properties of that pipeline you should design around:

  • Only the ROOT manifest is cached and shipped. A suite root deliberately carries no configSchema/uiSchema — those live in per-widget sub-manifests fetched on demand by the dashboard alone. Keep the root small; it rides along with every render.
  • The manifest attached is the kill-switch-filtered one. Suspended plugins get no manifest at all, and widget summaries switched off platform-wide or on that feed are stripped before the renderer ever sees them. See ship/install-and-kill-switches.md.
  • Path-only asset values resolve against plugin_manifest_url, preserving subpaths. dist/stay-hero/stay-hero.iife.js against https://…/prod/pdp-suite/1.3.3/manifest.json becomes https://…/prod/pdp-suite/1.3.3/dist/stay-hero/stay-hero.iife.js. Ship path-only values; absolute URLs bypass the whole mechanism and are a legacy shape.
  • The hashed build manifest is preferred over the proxy. For a pipeline-published plugin the renderer reads your dist/manifest.json and emits the content-addressed file directly, so a cached page always loads the exact bundle its HTML was rendered with. The version-less /p/{slug}/{file} proxy is the fallback. Both are documented in assets-and-urls.md.

Full example — a real suite root

// /Users/.../hr-plugins/pdp-suite/manifest.json (published v1.3.3), widgets[] trimmed
{
  "id": "pdp-suite",                       // the slug; permanent, globally unique
  "version": "1.3.3",                      // MAJOR.MINOR.PATCH, strictly increasing
  "name": "PDP Suite",
  "description": "A remix of the platform's property-details layout…",
  "readme": "media/README.md",             // one nesting level allowed; <= 64 KB
  "author": { "name": "HomeRunner QA", "url": "https://homerunner.io" },
  "license": "MIT",
  "tags": ["pdp", "layout", "property"],   // first 12 shown
  "runtime": {
    "react": "^19.0.0",                    // metadata only — nothing reads it
    "widgetCore": "0.12.0"                 // STAMPED BY THE BUILD. Never hand-write.
  },
  "shadowDOM": true,                       // whole-plugin; must match every mount() call

  "widgets": [                             // presence => suite shape. Max 24.
    { "slug": "pdp-frame",  "name": "PDP Frame",  "category": "layout", "…": "…" },
    { "slug": "stay-hero",  "name": "Stay Hero",  "…": "…" },
    { "slug": "stay-facts", "name": "Stay Facts", "ssr": false, "…": "…" },
    { "slug": "booking-cta","name": "Booking CTA","ssr": false, "…": "…" }
  ],

  // Absolute URLs: these render in the marketplace and the review console BEFORE the
  // first publish. `media/…` values would resolve to null until manifest_url exists.
  "icon":  "https://picsum.photos/seed/pdp-suite-icon/256/256",
  "cover": "https://picsum.photos/seed/pdp-suite-cover/1200/400",

  "presets": [                             // max 8; slot children are own slugs or
    {                                      // bare system keywords, never plugin: refs
      "name": "PDP Starter",
      "layout": "pdp-frame",
      "slots": {
        "hero":    ["stay-hero", "property-title", "gallery"],
        "main":    ["stay-facts", "description", "amenities", "calendar"],
        "sidebar": ["booking", "booking-cta"],
        "bottom":  ["reviews"]
      }
    }
  ]
}

Every field inside widgets[] is specified in widget-summary.md.

Full example — a v1 root

// packages/create-hr-plugin/template/manifest.json (placeholders substituted)
{
  "id": "my-widget",
  "version": "1.0.0",
  "name": "My Widget",
  "description": "A HomeRunner widget plugin",
  "author": { "name": "Plugin Author" },
  "widgetType": "my-widget",               // required; its VALUE is never read
  "ssr":    { "url": "dist/my-widget-ssr.umd.js" },
  "assets": {
    "js":  "dist/my-widget.iife.js",
    "css": "dist/my-widget.css"            // optional
  },
  "runtime": { "react": "^19.0.0" }        // build adds "widgetCore"
  // build also injects "configSchema" and "uiSchema" here
}

See also