What problem does this solve?
Blue has no catalog of its own. The "curated" list an admin sees under Extensions is package_catalog in that deployment's own deploy/blue.yaml (deploy/blue.yaml:104, read by read_package_catalog at services/control-api/src/lib.rs:6262, served admin-only from GET /admin/package-catalog at lib.rs:1636 / handler lib.rs:6678). Consequences today:
- Every org hand-authors the same recipes. Adding a popular tool means writing a digest-pinned
ManagedPackage block by hand, into a file the deployment operator owns, and reloading config to change it. Nothing is shared between orgs.
- Nothing useful ships curated. There is no Atlassian, Linear, GitHub MCP, Word/Excel, or common-skills listing anywhere in the repo — the shipped sample catalog is a single demo package.
- There is no discovery surface.
apps/dashboard/app/(dashboard)/configuration/package-manager.tsx is an inventory table of what you already configured, split by component capability (ExtensionTab at :104, tabs at :117). The only provenance signal is a "Curated"/"Custom" badge (:328, :833). There is nothing to browse.
- Enablement is entirely admin-gated.
PUT /admin/governance-extensions (lib.rs:1646) is behind UserPolicy::Admin → require_admin (lib.rs:3176), the Extensions page calls requireAdminIdentity() (extensions/page.tsx, lib/api.ts:50), and /extensions lives in adminItems in components/dashboard-sidebar.tsx:54-59, which renders only when role === "admin" (:151). A member has no extensions navigation at all and files a ticket to get a skill turned on.
The ask is a Blue-managed extension hub: a curated, hosted, versioned catalog with public-vs-internal browsing and per-user self-serve enablement, so orgs stop re-deriving the same recipes and developers stop filing tickets for a skill.
One scope finding worth stating up front: the marquee listings people ask for (Atlassian, Linear, GitHub MCP) are MCP servers, not packages. McpServer (crates/gh-service/src/schema.rs:518, attached org-wide per harness at HarnessPolicy.mcp, schema.rs:128) has no stable id (it is keyed by name), no digest pinning, and no audience scoping whatsoever — the dashboard hardcodes Audience = "Everyone" for MCP rows (package-manager.tsx:889). Packages, by contrast, already have ids, digests, and an audience mechanism. So self-serve realistically lands package-first, and the MCP half needs a design-review decision rather than an implementation guess. Details in the open questions below.
Proposed solution
Five independently shippable phases. Each is useful alone; none requires the next.
Phase 1 — the catalog lives in this repo and publishes separately.
catalog/extensions/<id>.yaml, one CODEOWNERS-gated file per listing, compiled into a generated catalog/dist/v1/index.json. A new crates/gh-catalog owns the schema so control-api later deserializes the same Rust types rather than a parallel copy that drifts, and ships a small validate | build binary. .github/workflows/catalog.yml validates on PR and re-downloads plus re-hashes every kind: package archive so a listing cannot merge with a digest that does not match its bytes. On merge, publish an immutable v1/<build-id>/index.json with a detached Ed25519 .sig, plus a v1/latest.json pointer. Blast radius is zero: nothing consumes the index yet.
One listing type, a tagged union, with no governance wire-schema change: kind: package carries a ManagedPackage verbatim (schema.rs:141, adapters at :180) and lands in GovernanceConfig.packages; kind: mcp carries an McpServer verbatim and lands in HarnessPolicy.mcp. Shared presentation and trust metadata (name, summary, publisher, categories, harnesses, auth, risk) wraps those structs rather than replacing them.
Phase 2 — control-api consumes the hub.
New control_api.extension_hub.* AppConfig fields; a background refresher gated on the existing run_background_jobs flag; the signature verified against a key pinned in the image (never fetched alongside the index); rollback rejected by a monotonic index_version; the result cached in a new extension_hub_index table. Resolution order is verified fetch → cached row → the deployment's own package_catalog (which keeps airgapped installs working). New GET /admin/extension-hub; GET /admin/package-catalog keeps returning byte-identical output.
Phase 3 — Public vs Internal tabs (admin).
The top-level axis becomes Hub | Internal, and today's capability tabs (ExtensionTab, package-manager.tsx:104) demote to a filter inside Internal. Switch the top level to the URL-synced, deep-linkable pattern already used by members/members-tabs.tsx:24-43, with role gating in the style of gateway/gateway-tabs.tsx. A new hub-gallery.tsx renders a card grid with honest chips ("Remote service", "Executable content", "Sign-in required") and a detail sheet offering Enable / Enable + self-serve. Extract a pure apps/dashboard/lib/extensions-view.ts (resolveExtensionsTab, partitionExtensions → available | enabled | self-serve, capabilityCounts) covered by node --test, mirroring lib/members-view.ts — the dashboard has no React test harness, so the testable logic has to live outside the component. Enabling reuses the existing PUT /admin/governance-extensions through saveExtensions (app/actions.ts:178); no new write endpoint.
Phase 4 — self-serve, packages first.
The admin-owned fact becomes a third audience scope: widen the CHECK in governance_package_audiences (services/control-api/migrations/0016_package_audiences.sql) from ('organization','users') to ('organization','users','self_serve'), so both existing enforcement sites — apply_package_audiences (lib.rs:5067) and the re-check in download_package_artifact (lib.rs:7627) — pick the new scope up structurally rather than by a parallel code path.
Per-user opt-ins go in a new, deliberately non-revision-scoped table extension_self_serve_optins (organization_id, user_id, package_id). They must not go in governance_package_audience_users: that table is revision-keyed and copied forward on every admin write by persist_package_audiences (lib.rs:3356), so a member toggling a skill would mint an org governance revision, contend on base_revision, and fan out over SSE to every client. Keying by (org, user, package_id) keeps opt-ins orthogonal to the revision timeline — zero new revisions per toggle. apply_package_audiences gains one predicate and GET /governance-config then returns the correct per-user set with no wire-schema change at all.
New member-facing routes under a new extensions:self-serve scope: GET /extensions/available and PUT /extensions/self-serve (whole-set PUT, 400 on any id the admin has not marked self_serve). Dashboard: move /extensions from adminItems to workspaceItems (dashboard-sidebar.tsx:53-59) and swap requireAdminIdentity() for requireIdentity() plus a role branch, keeping every /admin/* fetch inside the admin branch.
Phase 5 — CLI /extensions.
One entry in COMMANDS (crates/gh-cli/src/supervisor.rs:276-330), after /agent. The control screen rejects slash-command arguments outright (supervisor.rs:3037-3043), so this has to be a chooser, and because enabling is a multi-select it is modeled on ResumeWizard (supervisor.rs:575) rather than the single-select ControlPrompt. gh-service gains list_self_serve_extensions / set_self_serve_extensions shaped like the existing download_package_artifact client call. On commit: PUT, invalidate the governance cache, then call the existing apply_text() so the runtime overlay under ~/.config/blue/runtime/<harness>/ is rewritten.
Be honest about reload here: harnesses read skills, plugins and MCP at launch, so this should follow the /direct pattern (supervisor.rs:2936) — apply now, then offer "Keep current session / Reload agent". It should not claim hot-reload it cannot deliver.
Trust and integrity. Digest pinning stays exactly as it is; the index is signed with a key pinned in the image; listing review is CODEOWNERS on catalog/. MCP is the weak spot and should be treated as such: a remote MCP server has no digest, so require an allowlisted, publisher-verified origin; forbid fetch-at-run stdio MCP commands (npx-style) so helpers must come from a digest-pinned package; and always disclose "remote service — prompts and tool data leave the org" on the listing. Nothing auto-enables, ever.
Compatibility. No contract_version bump is needed (it is 3 at schema.rs:63): self-serve is server-side subtraction from a per-user config, mechanically identical to the already-shipped users scope. Add self_serve_extensions to CAPABILITIES (schema.rs:64). Whether it is advisory or gets pushed into required_capabilities by stamp_version_aware_client_floor (lib.rs:8064, following the unverified_harness_versions precedent at :8098-8102, which 426s older clients) is a core-team call — my recommendation is advisory, because the required form would 426 every older CLI the instant an admin marks one extension self-serve. Contract changes land in deploy/contract/governance.openapi.yaml plus npm run sync:contract.
Open questions for design review (the template routes UI/core features here, and these are the decisions I do not think an implementer should make alone):
- Who owns the credential for OAuth remote MCP (Atlassian, Linear)? Harness-native OAuth with Blue governing availability only, or a Blue-brokered per-user secret store?
McpServer.env is org-wide plaintext readable by every user, so Blue cannot hold a per-user token today. This blocks the marquee listings.
- MCP has no audience scoping and no stable id. Ship package-only self-serve in v1, with Atlassian/Linear as admin-enable-only listings, and defer MCP self-serve until per-user MCP config and MCP identity exist?
- Index signing key: baked into the image? What is the rotation story for airgapped deployments that never reach the CDN?
- Is
self_serve a third mutually-exclusive scope, or an orthogonal boolean ("self-serve, but only offered to these 12 people")? The mutually-exclusive form is much simpler and is what Phase 4 assumes.
- Does an opt-in survive an admin removing and re-adding the same
package_id? A non-revision-scoped table says yes by default. Is that wanted, or should removal purge opt-ins?
- Package ids are constrained to
[a-z0-9-] by validate_packages (lib.rs:6276). Widen for publisher/name, or keep flat publisher-name ids?
- Hub privacy: a static signed index means Blue learns nothing about which org enabled what. Confirm that is intentional and that no enablement telemetry gets added later by default.
- Rate limiting and abuse handling on
PUT /extensions/self-serve.
Happy to split this into per-phase issues if that is easier to schedule; Phase 1 is self-contained and has no runtime consumer.
Before submitting
Searched all open and closed issues and found nothing on an extension catalog, hub, marketplace, plugin registry, or self-serve enablement. The closest prior art is #44 (closed), which touched custom extension ranges being read-only — related surface, different problem. The closest existing feature is the per-deployment package_catalog in deploy/blue.yaml, which is not it: it is operator-owned static YAML, admin-only, unversioned, unsigned, not hosted by Blue, not shared across orgs, and has no browse surface.
What problem does this solve?
Blue has no catalog of its own. The "curated" list an admin sees under Extensions is
package_catalogin that deployment's owndeploy/blue.yaml(deploy/blue.yaml:104, read byread_package_catalogatservices/control-api/src/lib.rs:6262, served admin-only fromGET /admin/package-catalogatlib.rs:1636/ handlerlib.rs:6678). Consequences today:ManagedPackageblock by hand, into a file the deployment operator owns, and reloading config to change it. Nothing is shared between orgs.apps/dashboard/app/(dashboard)/configuration/package-manager.tsxis an inventory table of what you already configured, split by component capability (ExtensionTabat:104, tabs at:117). The only provenance signal is a "Curated"/"Custom" badge (:328,:833). There is nothing to browse.PUT /admin/governance-extensions(lib.rs:1646) is behindUserPolicy::Admin→require_admin(lib.rs:3176), the Extensions page callsrequireAdminIdentity()(extensions/page.tsx,lib/api.ts:50), and/extensionslives inadminItemsincomponents/dashboard-sidebar.tsx:54-59, which renders only whenrole === "admin"(:151). Amemberhas no extensions navigation at all and files a ticket to get a skill turned on.The ask is a Blue-managed extension hub: a curated, hosted, versioned catalog with public-vs-internal browsing and per-user self-serve enablement, so orgs stop re-deriving the same recipes and developers stop filing tickets for a skill.
One scope finding worth stating up front: the marquee listings people ask for (Atlassian, Linear, GitHub MCP) are MCP servers, not packages.
McpServer(crates/gh-service/src/schema.rs:518, attached org-wide per harness atHarnessPolicy.mcp,schema.rs:128) has no stable id (it is keyed byname), no digest pinning, and no audience scoping whatsoever — the dashboard hardcodes Audience = "Everyone" for MCP rows (package-manager.tsx:889). Packages, by contrast, already have ids, digests, and an audience mechanism. So self-serve realistically lands package-first, and the MCP half needs a design-review decision rather than an implementation guess. Details in the open questions below.Proposed solution
Five independently shippable phases. Each is useful alone; none requires the next.
Phase 1 — the catalog lives in this repo and publishes separately.
catalog/extensions/<id>.yaml, one CODEOWNERS-gated file per listing, compiled into a generatedcatalog/dist/v1/index.json. A newcrates/gh-catalogowns the schema so control-api later deserializes the same Rust types rather than a parallel copy that drifts, and ships a smallvalidate | buildbinary..github/workflows/catalog.ymlvalidates on PR and re-downloads plus re-hashes everykind: packagearchive so a listing cannot merge with a digest that does not match its bytes. On merge, publish an immutablev1/<build-id>/index.jsonwith a detached Ed25519.sig, plus av1/latest.jsonpointer. Blast radius is zero: nothing consumes the index yet.One listing type, a tagged union, with no governance wire-schema change:
kind: packagecarries aManagedPackageverbatim (schema.rs:141, adapters at:180) and lands inGovernanceConfig.packages;kind: mcpcarries anMcpSerververbatim and lands inHarnessPolicy.mcp. Shared presentation and trust metadata (name,summary,publisher,categories,harnesses,auth,risk) wraps those structs rather than replacing them.Phase 2 — control-api consumes the hub.
New
control_api.extension_hub.*AppConfigfields; a background refresher gated on the existingrun_background_jobsflag; the signature verified against a key pinned in the image (never fetched alongside the index); rollback rejected by a monotonicindex_version; the result cached in a newextension_hub_indextable. Resolution order is verified fetch → cached row → the deployment's ownpackage_catalog(which keeps airgapped installs working). NewGET /admin/extension-hub;GET /admin/package-catalogkeeps returning byte-identical output.Phase 3 — Public vs Internal tabs (admin).
The top-level axis becomes Hub | Internal, and today's capability tabs (
ExtensionTab,package-manager.tsx:104) demote to a filter inside Internal. Switch the top level to the URL-synced, deep-linkable pattern already used bymembers/members-tabs.tsx:24-43, with role gating in the style ofgateway/gateway-tabs.tsx. A newhub-gallery.tsxrenders a card grid with honest chips ("Remote service", "Executable content", "Sign-in required") and a detail sheet offering Enable / Enable + self-serve. Extract a pureapps/dashboard/lib/extensions-view.ts(resolveExtensionsTab,partitionExtensions→available | enabled | self-serve,capabilityCounts) covered bynode --test, mirroringlib/members-view.ts— the dashboard has no React test harness, so the testable logic has to live outside the component. Enabling reuses the existingPUT /admin/governance-extensionsthroughsaveExtensions(app/actions.ts:178); no new write endpoint.Phase 4 — self-serve, packages first.
The admin-owned fact becomes a third audience scope: widen the CHECK in
governance_package_audiences(services/control-api/migrations/0016_package_audiences.sql) from('organization','users')to('organization','users','self_serve'), so both existing enforcement sites —apply_package_audiences(lib.rs:5067) and the re-check indownload_package_artifact(lib.rs:7627) — pick the new scope up structurally rather than by a parallel code path.Per-user opt-ins go in a new, deliberately non-revision-scoped table
extension_self_serve_optins (organization_id, user_id, package_id). They must not go ingovernance_package_audience_users: that table is revision-keyed and copied forward on every admin write bypersist_package_audiences(lib.rs:3356), so a member toggling a skill would mint an org governance revision, contend onbase_revision, and fan out over SSE to every client. Keying by(org, user, package_id)keeps opt-ins orthogonal to the revision timeline — zero new revisions per toggle.apply_package_audiencesgains one predicate andGET /governance-configthen returns the correct per-user set with no wire-schema change at all.New member-facing routes under a new
extensions:self-servescope:GET /extensions/availableandPUT /extensions/self-serve(whole-set PUT, 400 on any id the admin has not markedself_serve). Dashboard: move/extensionsfromadminItemstoworkspaceItems(dashboard-sidebar.tsx:53-59) and swaprequireAdminIdentity()forrequireIdentity()plus a role branch, keeping every/admin/*fetch inside the admin branch.Phase 5 — CLI
/extensions.One entry in
COMMANDS(crates/gh-cli/src/supervisor.rs:276-330), after/agent. The control screen rejects slash-command arguments outright (supervisor.rs:3037-3043), so this has to be a chooser, and because enabling is a multi-select it is modeled onResumeWizard(supervisor.rs:575) rather than the single-selectControlPrompt.gh-servicegainslist_self_serve_extensions/set_self_serve_extensionsshaped like the existingdownload_package_artifactclient call. On commit: PUT, invalidate the governance cache, then call the existingapply_text()so the runtime overlay under~/.config/blue/runtime/<harness>/is rewritten.Be honest about reload here: harnesses read skills, plugins and MCP at launch, so this should follow the
/directpattern (supervisor.rs:2936) — apply now, then offer "Keep current session / Reload agent". It should not claim hot-reload it cannot deliver.Trust and integrity. Digest pinning stays exactly as it is; the index is signed with a key pinned in the image; listing review is CODEOWNERS on
catalog/. MCP is the weak spot and should be treated as such: a remote MCP server has no digest, so require an allowlisted, publisher-verified origin; forbid fetch-at-run stdio MCP commands (npx-style) so helpers must come from a digest-pinned package; and always disclose "remote service — prompts and tool data leave the org" on the listing. Nothing auto-enables, ever.Compatibility. No
contract_versionbump is needed (it is 3 atschema.rs:63): self-serve is server-side subtraction from a per-user config, mechanically identical to the already-shippedusersscope. Addself_serve_extensionstoCAPABILITIES(schema.rs:64). Whether it is advisory or gets pushed intorequired_capabilitiesbystamp_version_aware_client_floor(lib.rs:8064, following theunverified_harness_versionsprecedent at:8098-8102, which 426s older clients) is a core-team call — my recommendation is advisory, because the required form would 426 every older CLI the instant an admin marks one extension self-serve. Contract changes land indeploy/contract/governance.openapi.yamlplusnpm run sync:contract.Open questions for design review (the template routes UI/core features here, and these are the decisions I do not think an implementer should make alone):
McpServer.envis org-wide plaintext readable by every user, so Blue cannot hold a per-user token today. This blocks the marquee listings.self_servea third mutually-exclusive scope, or an orthogonal boolean ("self-serve, but only offered to these 12 people")? The mutually-exclusive form is much simpler and is what Phase 4 assumes.package_id? A non-revision-scoped table says yes by default. Is that wanted, or should removal purge opt-ins?[a-z0-9-]byvalidate_packages(lib.rs:6276). Widen forpublisher/name, or keep flatpublisher-nameids?PUT /extensions/self-serve.Happy to split this into per-phase issues if that is easier to schedule; Phase 1 is self-contained and has no runtime consumer.
Before submitting
Searched all open and closed issues and found nothing on an extension catalog, hub, marketplace, plugin registry, or self-serve enablement. The closest prior art is #44 (closed), which touched custom extension ranges being read-only — related surface, different problem. The closest existing feature is the per-deployment
package_catalogindeploy/blue.yaml, which is not it: it is operator-owned static YAML, admin-only, unversioned, unsigned, not hosted by Blue, not shared across orgs, and has no browse surface.