skip to content
docsspec
github

UI slots — a native button, zero coupling

The portal keeps a small registry table, ui_extensions. Each row is one contribution to a named slot: "in this slot, render a button with this label that goes to this URL." Product apps read their slots at render time and draw whatever they find as native buttons — same design system, same feel. The core ships the socket; the addon ships the plug.

An empty slot renders nothing. That is the uninstall story: delete the rows and the UI is gone, and the core never knew the addon's name.

FieldMeaning
keyUnique id, by convention <addon>.<purpose> (e.g. acquire.search-request)
addonOwning addon id — enables bulk removal of everything an addon contributed
slotWhere it appears (see the catalog below)
kindlink (navigate) or action (the client POSTs to url)
label, iconButton text and a lucide icon name (safe fallback if unknown)
urlDestination. Placeholders are substituted client-side: {q} = the current search query
status_urlOptional: a feed the client may poll to decorate the button with live state
ord, enabledOrdering within the slot; kill-switch
  • Write — POST /api/portal/extensions (upsert by key), PATCH/DELETE /api/portal/extensions/{key}. Callers need the admin role or the addon role — see identity for the honest state of the addon role.
  • Read — GET /api/portal/slots/{slot} returns the enabled rows for one slot, ordered. Any signed-in user; product apps forward the user's bearer.

Product apps don't call portal-api directly from the browser in every case: chino-api proxies the read (GET /api/v1/extensions?slot=…) best-effort, so an unreachable portal yields an empty slot rather than an error.

A product app renders a row as a link, or POSTs to it with the signed-in user's bearer, so portal-api checks where a row leads when it is written — on install from a manifest, and in the write API — by one rule. A notice's link is held to the same rule; one function decides both.

  • kind is link or action; an action is sent as a POST.
  • url, and status_url when set, is a path on this instance (/portal/app/<addon>?q={q}) or an absolute http(s) URL on the instance's own origin — never another host, never javascript:, data: or any other scheme, never //host, credentials, or . and .. segments, spelled out or percent-encoded.
  • An action an addon contributes leads to its own API through the portal's proxy, /api/portal/apps/<addon>/…: it carries the user's token.

A row written before these rules that breaks them is not served.

The catalog is deliberately explicit: a slot exists when a product app renders it, not when a doc mentions it.

SlotRendered byWhen it shows
search.emptychino-web (search page)A search returned no titles and no people

That is the entire catalog today — one slot. 🧭 Roadmap: item detail actions and portal-shell slots; they will be added to this table when a client actually renders them, and not before.

Installing acquire registers one row:

{
"key": "acquire.search-request",
"addon": "acquire",
"slot": "search.empty",
"kind": "link",
"label": "Request this",
"icon": "download",
"url": "/portal/app/acquire?q={q}#/discover",
"ord": 10,
"enabled": true
}

"No results for X" grows a Request this button that carries the query into the addon's console. Delete the row and it is gone.