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

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_cache with 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

StepWhereWhat it changesWhat your code sees
InstallMarketplace card → InstallCreates the (feed, plugin) row with enabled = trueYour widgets appear in that feed’s picker
Activate / DeactivateInstalled tab, or the plugin pageFlips enabled onlyDeactivating stops every instance rendering
Per-widget togglesPlugin page → Widgets tab, one switch per widgetThe feed’s own withheld-slug listSuite only; see the kill-switch table
ConfigureInstalled row → ConfigurePer-feed plugin config JSONNothing — see the gap below
Create widgetFeed → Widgets → Create New WidgetA widget row storing your keywordYour component renders
UninstallInstalled row → UninstallDeletes the row: config, sort order and toggles go with itGuarded — 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 a configSchema — 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 by GET /{feed_id}/feed/plugins and 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 not deprecated, keyword plugin:{slug}:{widget}, plus one card per surviving presets[] 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.

SurfaceSourceNotes
Cover band + icon + author + versionroot cover, icon, author, current_versionFalls back to a slug gradient and letter tile
Description tabdescription, then root readmeWith no README: This plugin ships no long description.
Widgets tabwidgets[], each with widgets[].readmeBadges: category, SSR/CSR only, deprecated; layouts also list their slots, prefills, accepts and pages
Screenshots tabscreenshotsTab is hidden when empty; click opens a lightbox
Changelog tabmedia/CHANGELOG.mdUndeclared — shipped by convention, tab hidden when absent
Sidebarversion, published_at, runtime.widgetCore, installs, widget names, homepage/support/docs, license, tagsLinks 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, content badge, 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
#SwitchScopeWhoEffect on assetsEffect on SSREffect on the picker
1status = suspendedThe whole plugin, everywhereHomeRunner admin/p/ 404s every file — the public lookup is approved-onlyEvery instance becomes a hidden failure breadcrumb; the page degradesGone from Marketplace; Installed rows keep a Suspended by HomeRunner badge and offer only Uninstall
2disabled_widgets on the pluginNamed widgets, every feedHomeRunner admin404s that widget’s entire dist/{widget}/ directory — bundles, images, fontsThose instances stop resolvingThe widget vanishes; so does any preset that references it
3disabled_widgets on the installNamed widgets, one feedThe customerNo effect/p/ is feed-agnosticThose instances stop resolving on that feed onlyGone 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):

RulePlatform listPer-feed list
Maximum slugs2424
Slug shapestring, ≤ 64 charsmust be a declared widget slug of the live manifest, else 422 INVALID_WIDGET_SLUG
Set byPATCH /plugins/{id} (admin) — chips on the admin registry rowThe plugin page’s per-widget switches
Cleared byRemoving the slugSending 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 layout or 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: trueKill switch
Who sets itYou, in widgets[]HomeRunner (1, 2) or the customer (3)
PickerGoneGone
Existing placementsKeep rendering, unchangedStop rendering
AssetsStill served404 (switches 1 and 2)
Visible to the customerYes — a deprecated badge on the plugin pageNo (1 and 2); yes (3)
Reversible byPublishing a new versionFlipping 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).