Install, customers and kill switches
Publishing puts your plugin in a catalogue. It does not put it on a page. A customer has to find it in their feed’s Marketplace, install it, activate it and create a widget from it — and they can undo every one of those steps. Above them sit two switches HomeRunner controls and your customer cannot.
You drive none of this. There is no author-facing API for installing, activating or suspending, and nothing notifies you when any of it changes (From your laptop to a customer page). Your only levers are shipping a new version and asking for a rollback (Publishing, versions and rollback).
Who can see your plugin
A plugin appears in a customer’s catalogue when it is status = approved and either
visibility = public or granted to their customer account. Grants are account-level on
purpose: granting one account gives its whole team the plugin. You always see your own rows at
any status — that is your submission dashboard, not the marketplace.
Visibility is chosen by the reviewer in the approve dialog (Public — every account can enable it / Private — only granted customer accounts) and an administrator can flip it, or add and
remove grants, at any time afterwards. You cannot set or read it yourself.
There is no global plugin browser. Customers reach plugins only from Feed → Plugins, which has two tabs: Installed and Marketplace. Authors get Dashboard → Plugins → My plugins and nothing else. So “your plugin is live” always means live for a feed.
Two consequences that surprise authors:
- The catalogue row a customer receives is already kill-switch filtered. Central replaces
manifest_cachewith the withheld-widget version for anyone who is not the owner or an admin, and hides every pipeline column (review notes, zip pointer, submitted manifest). You, the owner, get the raw row — so a widget HomeRunner has disabled is visible to you and simply absent for them. Debug against a second account, not your own. - The install count is an active count.
active_installs, shown on the marketplace card and in the plugin-page sidebar, counts feeds where the plugin is currently enabled. A customer who deactivates without uninstalling silently decrements it.
What a customer actually does
| Step | Where | What it changes | What your code sees |
|---|---|---|---|
| Install | Marketplace card → Install | Creates the (feed, plugin) row with enabled = true | Your widgets appear in that feed’s picker |
| Activate / Deactivate | Installed tab, or the plugin page | Flips enabled only | Deactivating stops every instance rendering |
| Per-widget toggles | Plugin page → Widgets tab, one switch per widget | The feed’s own withheld-slug list | Suite only; see the kill-switch table |
| Configure | Installed row → Configure | Per-feed plugin config JSON | Nothing — see the gap below |
| Create widget | Feed → Widgets → Create New Widget | A widget row storing your keyword | Your component renders |
| Uninstall | Installed row → Uninstall | Deletes the row: config, sort order and toggles go with it | Guarded — see below |
Install and Activate are the same call. Installing already activates; the Marketplace button
reads Install when there is no row and Activate when there is a disabled one. The upsert is
idempotent and preserves an existing row’s stored config and sort order, so re-activating
restores whatever the customer had. Central refuses the call with PLUGIN_NOT_APPROVED for a
plugin that is not live and PLUGIN_NOT_AVAILABLE (403) for an approved plugin the account is
not entitled to.
Deactivate is the customer’s own whole-plugin switch. It keeps the row, the config and the per-widget toggles, and it is not a delete — but the public widget list only attaches your manifest for a plugin that is approved and enabled on that feed, so on a deactivated plugin every widget instance stops resolving and renders the same hidden failure breadcrumb a suspension produces (The two manifest shapes). The rows survive; reactivating brings them all back.
Uninstall is guarded. Central refuses with 409 PLUGIN_IN_USE while any widget built from
your plugin still exists on the feed, and returns the count in details.widget_count
(Limits and error index). The dialog then switches to a
guided path: it lists those widgets with their keywords, makes the customer type your slug to
confirm, deletes them one at a time and only then uninstalls. There is no silent cascade, and
paused widgets count too.
Not supported yet. Per-feed plugin config (
Configure) is offered only when the root manifest carries aconfigSchema— a v1 field a suite must not have (Manifest: root fields), so for a suite the button never appears. And nothing on any render path delivers the stored value to your component: it is returned byGET /{feed_id}/feed/pluginsand read by nothing else (Public API). Treat widget settings as the only configuration surface (Settings and options).
Creating a widget from your plugin
The Create New Widget picker is fed by the public feed-plugin list, so it already has every switch applied: approved, enabled on the feed, and both kill switches subtracted. What is left becomes cards (Both):
- Single-widget: exactly one card, keyword
plugin:{slug}. - Suite: one card per
widgets[]entry that is notdeprecated, keywordplugin:{slug}:{widget}, plus one card per survivingpresets[]entry listed ahead of them.
Icons resolve widgets[].icon → the plugin’s root icon → a glyph, and a URL that fails to
load falls back to the glyph too. The glyph is a Layers icon for a layout and a Puzzle
piece for a content widget — the same pair the plugin page uses. Your plugin’s own avatar falls
back to a slug-coloured letter tile, and the cover band to a slug gradient, so an icon-less
plugin still looks deliberate. The picker, presets and slot editor are covered end to end in
Composing a page.
Your plugin page carries the same shortcut: a Create widget button per widget, enabled only
when the plugin is installed and activated on this feed, the widget is not withheld by
either kill switch, and the summary is not deprecated.
What your plugin page shows
Feed → Plugins → any plugin name opens a full page — the customer’s whole impression of your
work, built from media/. It opens for installed and not-yet-installed plugins.
| Surface | Source | Notes |
|---|---|---|
| Cover band + icon + author + version | root cover, icon, author, current_version | Falls back to a slug gradient and letter tile |
| Description tab | description, then root readme | With no README: This plugin ships no long description. |
| Widgets tab | widgets[], each with widgets[].readme | Badges: category, SSR/CSR only, deprecated; layouts also list their slots, prefills, accepts and pages |
| Screenshots tab | screenshots | Tab is hidden when empty; click opens a lightbox |
| Changelog tab | media/CHANGELOG.md | Undeclared — shipped by convention, tab hidden when absent |
| Sidebar | version, published_at, runtime.widgetCore, installs, widget names, homepage/support/docs, license, tags | Links render only when they match ^https?:// |
Every markdown file is fetched server-side from your immutable version prefix, size-capped
again on read, and rendered through a sanitiser — no raw HTML, no scripts. Because they resolve
against manifest_url, nothing under media/ renders until your first publish: absolute
icon/cover URLs show up in review, media/ paths do not, and screenshots (which only
accept media/ paths) never appear before then
(Manifest: root fields).
Not supported yet. A single-widget plugin gets a synthetic card on the Widgets tab — plugin icon, name,
contentbadge,plugin:{slug}keyword, description and a Create widget button — but no per-widget README, no SSR/CSR badge, no instance count, no per-feed toggle and no count in the tab label. Every one of those is suite-only (The two manifest shapes).
The three kill switches
Three switches can withhold your code, at three scopes. They compose by subtraction only: each one narrows the manifest the one below it starts from, so nothing a customer does can restore something the platform withheld.
// Source: homerunner-central/app/Models/Plugin.php:145-172 (publicManifest,
// publicManifestForFeed) + app/PublicApi/V1/Controllers/WidgetController.php:64-71
manifest_cache the published manifest
└─ status must be `approved` (1) suspension — else nothing resolves at all
└─ minus plugins.disabled_widgets (2) platform per-widget
└─ minus feed_plugins.disabled_widgets (3) per-feed, on top
└─ requires feed_plugins.enabled the customer's Deactivate
| # | Switch | Scope | Who | Effect on assets | Effect on SSR | Effect on the picker |
|---|---|---|---|---|---|---|
| 1 | status = suspended | The whole plugin, everywhere | HomeRunner admin | /p/ 404s every file — the public lookup is approved-only | Every instance becomes a hidden failure breadcrumb; the page degrades | Gone from Marketplace; Installed rows keep a Suspended by HomeRunner badge and offer only Uninstall |
| 2 | disabled_widgets on the plugin | Named widgets, every feed | HomeRunner admin | 404s that widget’s entire dist/{widget}/ directory — bundles, images, fonts | Those instances stop resolving | The widget vanishes; so does any preset that references it |
| 3 | disabled_widgets on the install | Named widgets, one feed | The customer | No effect — /p/ is feed-agnostic | Those instances stop resolving on that feed only | Gone from that feed’s picker; the switch stays visible so it can be turned back on |
Rules and caps for the two per-widget lists (Suite only — a single-widget plugin has nothing
to name, and central rejects the attempt with NOT_MULTI_WIDGET):
| Rule | Platform list | Per-feed list |
|---|---|---|
| Maximum slugs | 24 | 24 |
| Slug shape | string, ≤ 64 chars | must be a declared widget slug of the live manifest, else 422 INVALID_WIDGET_SLUG |
| Set by | PATCH /plugins/{id} (admin) — chips on the admin registry row | The plugin page’s per-widget switches |
| Cleared by | Removing the slug | Sending an empty list |
Three consequences worth designing for:
- A withheld widget is invisible, not broken. Its summary is gone from the manifest a customer’s dashboard receives, so they see no widget, no badge and no explanation — only you and admins can see it was withheld. Existing widget rows survive untouched and come straight back when the slug is removed.
- Presets are collateral. Central drops any preset whose
layoutor whose slot children include a withheld slug, so a one-click composition disappears the moment one of its parts is switched off. Design presets out of widgets you expect to keep (Layout widgets). - Assets stop with switch 1 and 2, not 3. A CSR embed of a per-feed-disabled widget on some
unrelated page keeps loading its bundle from
/p/— the proxy resolves against the platform-filtered manifest and knows nothing about feeds (Keywords, assets and URLs).
Nothing is deleted from the CDN. Every switch works by withholding the manifest, and your
published objects stay exactly where they were. That is why the dashboard fires a purge across
every feed with the plugin installed on each of these changes: a suspension lands in seconds
instead of waiting out the composed-page cache
(Publishing, versions and rollback). Publishing a new
version sets status = approved again, so a publish also un-suspends.
deprecated is not a kill switch
deprecated: true is yours; the kill switches are not. They look similar in the picker and are
opposites everywhere else.
deprecated: true | Kill switch | |
|---|---|---|
| Who sets it | You, in widgets[] | HomeRunner (1, 2) or the customer (3) |
| Picker | Gone | Gone |
| Existing placements | Keep rendering, unchanged | Stop rendering |
| Assets | Still served | 404 (switches 1 and 2) |
| Visible to the customer | Yes — a deprecated badge on the plugin page | No (1 and 2); yes (3) |
| Reversible by | Publishing a new version | Flipping the switch |
Deprecating is how you retire a widget from a suite. Deleting its slug instead breaks every
customer widget that stored the keyword (Manifest: widget summary).
Related
- From your laptop to a customer page — the pipeline that got you here
- Preflight and submit · Publishing, versions and rollback
- Composing a page — the picker, presets and page assignment in full
- Public API — the endpoints that expose install state
- Limits and error index — every code and message named here
- Troubleshooting — “my widget stopped rendering on one feed”