Status: design RFC with a working prototype. The panel slot (FAB action + shared right panel + POST /api/web-panel/invoke) and a global-notepad extension exercising it are implemented and validated locally (unit + e2e + live browser). An audit of every known extension (repo examples + installed) confirmed all of them use either pi-core APIs, the settings platform, or the five set* APIs — all preserved under this proposal.
RFC: pi-web extension architecture — contributions, slots, and kinds
Status: draft for review · Scope: pi-web browser-facing extensibility
1. Motivation
pi-web currently exposes five browser contribution APIs (setFooter, setHeaderAction,
setArtifactAction, setGitTab, setPanel). Each one re-implements the same six layers:
an API method, a per-session registry, a serializer + wire event, a snapshot field +
frontend reducer, an invoke endpoint + sanitizer, and (for interactive surfaces) its own
data-* callback dialect. Adding surface #6 (setPanel) touched seven files whose content
was structurally identical to the previous five. Cost per new surface is O(n) bespoke code
for O(1) novelty. Interactive and asset-serving extensions (ECharts, live dashboards,
webview editors) would each add more bespoke paths under the current model.
2. Tenets
- One kernel, surfaces as data. Registry, wire protocol, invoke/asset endpoints and
sanitization are written once. New capability = new slot or new kind, never a new
bespoke API.
- Capability kinds, lowest-kind-wins. Extensions start declarative and upgrade only
the pieces that need richness. Documentation presents a default path with upgrade
triggers, not a matrix.
- Extension code never runs in the main app DOM. Server-side logic talks through
envelopes; rich client code (webviews) runs in a sandboxed iframe behind a tiny,
frozen bridge. This protects core refactorability as much as security.
- Graceful degradation. Slots are strings; descriptors are versioned; unknown
slot/kind/field = ignored, never an error surfaced to the user.
3. Core concepts
- Contribution — the unit an extension registers: a descriptor plus optional content.
- Slot — a named surface in pi-web that hosts contributions (
footer,
header-action, artifact-action, git-tab, panel, …). Slot hosts are the only
legitimately bespoke frontend code.
- Kind — how a contribution delivers content:
static, rendered, or webview.
- Facilities — orthogonal services any contribution can use: assets (files served
at stable URLs) and invalidation (server-initiated refresh).
4. Extension-facing API
ctx.ui.web.contribute(key: string, spec: Contribution | undefined): void;
ctx.ui.web.update(key: string): void; // invalidation: mark stale, hosts re-fetch
type Contribution = {
slot?: string; // omit for pure asset bundles
// kind is inferred: view → static, render → rendered, entry → webview
title?: string; label?: string; icon?: string;
match?: { kinds?: string[]; extensions?: string[] }; // slot-interpreted filters
view?: View; // kind: static
render?: (event?: Event) => View | Promise<View>; // kind: rendered
entry?: string; // kind: webview (asset key of iframe entry)
onMessage?: (message: unknown, reply: (m: unknown) => void) => void; // webview bridge
assets?: Record<string, string>; // fileName → absolute path, served at
// /api/web-assets/<key>/<fileName>
};
Legacy APIs (setFooter, setHeaderAction, setArtifactAction, setGitTab,
setPanel) become one-line wrappers over contribute() and remain indefinitely as the
typed, documented convenience layer. registerSettings stays a separate platform
(persistence + validation is a different problem than rendering).
5. Envelopes
One interaction vocabulary shared by every slot:
type Event = {
action?: string; // from data-web-action on the clicked element / submitted form
payload?: unknown; // extension-authored JSON from data-web-payload
fields?: Record<string, string | string[]>; // serialized form controls
context?: Record<string, unknown>; // slot-supplied (repo, artifact, …)
};
type View = {
title?: string;
html?: string; // full surface re-render
markdown?: string; // rendered popover/card
message?: string; // plain-text acknowledgement
composerContext?: { id?: string; label: string; title?: string; content: string };
download?: { filename?: string };
};
render(undefined) is the initial render. Which View fields a slot accepts is defined
by the slot policy table (§7), enforced by one shared sanitizer with per-slot budgets.
One action grammar everywhere: data-web-action, data-web-payload, plus HTML form
serialization. Existing dialects (data-web-git-tab-action, data-web-panel-action)
are accepted as aliases during migration.
6. Kinds
| Kind |
Delivery |
Client code |
Use when |
static |
descriptor + view pushed in session snapshots |
none |
footers, labels, badges |
rendered |
POST /api/web-contributions/invoke, Event → View |
none (server-rendered) |
forms, lists, planners, dashboards |
webview |
sandboxed iframe loading extension assets; postMessage bridge |
extension-authored JS |
canvas, drag-drop, terminals, editors |
Facilities compose with kinds:
- Assets: any contribution may carry
assets; a contribution with only assets and
no slot is a pure library bundle (e.g. ECharts for HTML artifacts). URLs are stable
(/api/web-assets/<key>/<file>), cache-friendly, and served while any live session
registers them. Auth must align with artifact-preview serving (sandboxed opaque-origin
iframes cannot attach bearer tokens); this is a deliberate security decision.
- Invalidation:
update(key) emits web_contribution_updated; hosts re-invoke
render() (rendered) or deliver a refresh message (webview). Stateless pull-after-push;
no streaming protocol.
Webview bridge (deliberately starved)
Client API acquired inside the iframe:
const api = acquirePiWebApi(); // { postMessage, onMessage, getState, setState, version }
No DOM access to the host app, no core UI hooks, no theming API in v1. The bridge is the
forever-contract; small contracts age well. CSP locked to the extension's own asset
namespace. Extension-side handler: onMessage.
7. Slot catalog v1
| Slot |
Kinds allowed |
Event.context |
View fields allowed |
Budget |
footer |
static |
— |
text lines / html |
20 KB |
header-action |
rendered |
— |
markdown |
200 KB |
artifact-action |
rendered |
artifact {name, path, kind} |
markdown, message, download |
200 KB |
git-tab |
rendered |
repo {path, root, branch} |
html, composerContext |
500 KB |
panel (FAB + right panel) |
rendered, webview |
— |
html |
500 KB |
Slot hosts own presentation (FAB fan geometry, drawer tabs, popovers). Adding a slot =
one frontend host + one policy row.
8. Wire protocol
- Session snapshots and
state_changed carry
webContributions: Array<{ key, slot, kind, title, label, icon, match }>.
- Delta event:
web_contributions_changed (same payload, session-scoped).
- Invalidation event:
web_contribution_updated { sessionId, key }.
- Interaction:
POST /api/web-contributions/invoke { sessionId, key, event } → sanitized View.
- Webview messaging:
POST /api/web-contributions/message { sessionId, key, message }
upstream; downstream messages ride the websocket envelope.
- Assets:
GET /api/web-assets/<key>/<path>.
9. Lifecycle
Contributions are session-scoped and auto-released on session_shutdown (explicit
contribute(key, undefined) remains available). Asset bundles remain served while any
live session registers the same key; asset URLs embed no session identity so artifacts
keep working across sessions. Webview client state (getState/setState) persists per
session.
10. Security & trust
- Extensions are trusted local code (they already run with full user permissions
server-side). Rendered HTML is trusted in the app DOM, size-budgeted and
control-character-sanitized — same model as today's footer/git-tab HTML.
- Webviews are sandboxed iframes with locked CSP; extension JS never runs in the app DOM.
- Install-time consent (via pi packages) should name the capability tier; a webview
extension is a visibly bigger grant than a footer.
11. Migration & phasing
| Phase |
Deliverable |
Proof |
| 1 |
Kernel consolidation behind existing APIs (behavior-preserving; net core LOC ↓) |
existing five surfaces keep passing current unit + e2e suites |
| 2 |
contribute() public + assets facility |
ECharts artifact extension |
| 3 |
Invalidation (update) |
notepad live across sessions; orchestrator status board |
| 4 |
Webview kind + versioned bridge |
chart editor / kanban notepad panel |
| 5 |
Distribution via pi packages, engines-style compat, dev harness |
third-party extension end-to-end |
12. Governance (complexity containment)
- New slot — routine PR (host + policy row).
- New View field — design review.
- New kind — RFC with a named exemplar extension that provably cannot be built one
kind down. No exemplar, no kind.
- Expectation (VS Code power law): the vast majority of contributions stay
static/rendered; webview remains the rare, expensive path.
13. Exemplars
- Notepad (rendered
panel): structured day-planner with provenance; validates
Event→View, forms, dedup, settings toggle. Implemented and validated.
- ECharts (pure assets + skill): agent-authored interactive charts in HTML
artifacts; validates the assets facility and artifact-auth alignment.
- Orchestrator board (rendered
panel + invalidation): live worker status.
- Chart/kanban editor (webview
panel): validates the bridge; sequenced last.
14. Out of scope
- pi core extension model (tools, commands, events, skills, packages) — already generic,
owned upstream.
- Settings persistence platform — separate, already schema-driven.
- Artifact preview pipeline — already an isolated, script-capable surface; assets plug
into it rather than replacing it.
15. Open questions
- Asset lifetime guarantees after uninstall (artifacts referencing a removed library).
- Whether
git-tab should eventually be re-expressed as a panel variant.
- Multi-panel policy (today: one shared extension panel active per side).
- Cross-extension composition (global command indirection à la VS Code) — deferred until
two real extensions need to invoke each other.
- FAB/launcher coupling: today a
panel contribution implicitly provides its FAB
launcher entry (the FAB is the panel slot host's affordance). Should a standalone
launcher-action slot exist for FAB entries that fire a verb without opening a
panel? Under the kernel this is a routine new slot; deferred until a real extension
needs it.
RFC: pi-web extension architecture — contributions, slots, and kinds
Status: draft for review · Scope: pi-web browser-facing extensibility
1. Motivation
pi-web currently exposes five browser contribution APIs (
setFooter,setHeaderAction,setArtifactAction,setGitTab,setPanel). Each one re-implements the same six layers:an API method, a per-session registry, a serializer + wire event, a snapshot field +
frontend reducer, an invoke endpoint + sanitizer, and (for interactive surfaces) its own
data-*callback dialect. Adding surface #6 (setPanel) touched seven files whose contentwas structurally identical to the previous five. Cost per new surface is O(n) bespoke code
for O(1) novelty. Interactive and asset-serving extensions (ECharts, live dashboards,
webview editors) would each add more bespoke paths under the current model.
2. Tenets
sanitization are written once. New capability = new slot or new kind, never a new
bespoke API.
the pieces that need richness. Documentation presents a default path with upgrade
triggers, not a matrix.
envelopes; rich client code (webviews) runs in a sandboxed iframe behind a tiny,
frozen bridge. This protects core refactorability as much as security.
slot/kind/field = ignored, never an error surfaced to the user.
3. Core concepts
footer,header-action,artifact-action,git-tab,panel, …). Slot hosts are the onlylegitimately bespoke frontend code.
static,rendered, orwebview.at stable URLs) and invalidation (server-initiated refresh).
4. Extension-facing API
Legacy APIs (
setFooter,setHeaderAction,setArtifactAction,setGitTab,setPanel) become one-line wrappers overcontribute()and remain indefinitely as thetyped, documented convenience layer.
registerSettingsstays a separate platform(persistence + validation is a different problem than rendering).
5. Envelopes
One interaction vocabulary shared by every slot:
render(undefined)is the initial render. WhichViewfields a slot accepts is definedby the slot policy table (§7), enforced by one shared sanitizer with per-slot budgets.
One action grammar everywhere:
data-web-action,data-web-payload, plus HTML formserialization. Existing dialects (
data-web-git-tab-action,data-web-panel-action)are accepted as aliases during migration.
6. Kinds
staticrenderedPOST /api/web-contributions/invoke, Event → ViewwebviewFacilities compose with kinds:
assets; a contribution with onlyassetsandno
slotis a pure library bundle (e.g. ECharts for HTML artifacts). URLs are stable(
/api/web-assets/<key>/<file>), cache-friendly, and served while any live sessionregisters them. Auth must align with artifact-preview serving (sandboxed opaque-origin
iframes cannot attach bearer tokens); this is a deliberate security decision.
update(key)emitsweb_contribution_updated; hosts re-invokerender()(rendered) or deliver a refresh message (webview). Stateless pull-after-push;no streaming protocol.
Webview bridge (deliberately starved)
Client API acquired inside the iframe:
No DOM access to the host app, no core UI hooks, no theming API in v1. The bridge is the
forever-contract; small contracts age well. CSP locked to the extension's own asset
namespace. Extension-side handler:
onMessage.7. Slot catalog v1
footerheader-actionartifact-action{name, path, kind}git-tab{path, root, branch}panel(FAB + right panel)Slot hosts own presentation (FAB fan geometry, drawer tabs, popovers). Adding a slot =
one frontend host + one policy row.
8. Wire protocol
state_changedcarrywebContributions: Array<{ key, slot, kind, title, label, icon, match }>.web_contributions_changed(same payload, session-scoped).web_contribution_updated { sessionId, key }.POST /api/web-contributions/invoke { sessionId, key, event }→ sanitizedView.POST /api/web-contributions/message { sessionId, key, message }upstream; downstream messages ride the websocket envelope.
GET /api/web-assets/<key>/<path>.9. Lifecycle
Contributions are session-scoped and auto-released on
session_shutdown(explicitcontribute(key, undefined)remains available). Asset bundles remain served while anylive session registers the same key; asset URLs embed no session identity so artifacts
keep working across sessions. Webview client state (
getState/setState) persists persession.
10. Security & trust
server-side). Rendered HTML is trusted in the app DOM, size-budgeted and
control-character-sanitized — same model as today's footer/git-tab HTML.
extension is a visibly bigger grant than a footer.
11. Migration & phasing
contribute()public + assets facilityupdate)12. Governance (complexity containment)
kind down. No exemplar, no kind.
static/rendered; webview remains the rare, expensive path.
13. Exemplars
panel): structured day-planner with provenance; validatesEvent→View, forms, dedup, settings toggle. Implemented and validated.
artifacts; validates the assets facility and artifact-auth alignment.
panel+ invalidation): live worker status.panel): validates the bridge; sequenced last.14. Out of scope
owned upstream.
into it rather than replacing it.
15. Open questions
git-tabshould eventually be re-expressed as apanelvariant.two real extensions need to invoke each other.
panelcontribution implicitly provides its FABlauncher entry (the FAB is the panel slot host's affordance). Should a standalone
launcher-actionslot exist for FAB entries that fire a verb without opening apanel? Under the kernel this is a routine new slot; deferred until a real extension
needs it.