All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m44s
CI/CD / test-sqlite (push) Successful in 2m54s
CI/CD / build-and-deploy (push) Successful in 52s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
688 lines
22 KiB
Markdown
688 lines
22 KiB
Markdown
# DESIGN — Extension Composability
|
||
|
||
**Version:** v0.8.5
|
||
**Status:** Implemented
|
||
**Author:** Jeff / Claude session 2026-04-02
|
||
|
||
---
|
||
|
||
## Problem
|
||
|
||
Extensions cannot meaningfully compose with each other. A Notes surface
|
||
can't accept toolbar buttons from an STT extension. An LLM bridge can't
|
||
invoke an image generator's functions. A chat surface can't display action
|
||
buttons contributed by an image-editing extension.
|
||
|
||
The runtime primitives for contribution exist — `sw.slots`, `sw.actions`,
|
||
and `sw.renderers` are live in the SDK. But there is no manifest layer
|
||
to declare the relationships, no backend mechanism for non-library packages
|
||
to call each other's exported functions, and no conventions for slot
|
||
naming or context contracts.
|
||
|
||
This blocks the entire "extensions extending extensions" pattern that
|
||
makes the platform an ecosystem rather than a collection of packages.
|
||
|
||
---
|
||
|
||
## Non-Goals
|
||
|
||
- Arbitrary inter-extension communication (message passing, shared memory).
|
||
Extensions compose through declared slots, exported functions, and events.
|
||
- Runtime dependency injection. Dependencies are declared in manifests and
|
||
resolved at load time.
|
||
- Extension sandboxing on the frontend. Browser-tier JS runs in the same
|
||
page context. Isolation is by convention and code review, not enforcement.
|
||
|
||
---
|
||
|
||
## What Already Exists
|
||
|
||
Three SDK registries are live and functional:
|
||
|
||
**`sw.slots`** — Named UI injection points. `register(name, {id, component, priority})`
|
||
adds a Preact component to a named slot. `get(name)` returns sorted entries.
|
||
Emits `slots.changed` events. Dedup by id. Priority ordering.
|
||
|
||
**`sw.actions`** — Named callable actions. `register(id, {handler, label, icon})`
|
||
exposes a function by name. `run(id, ...args)` invokes it. Cross-extension
|
||
function calls on the frontend.
|
||
|
||
**`sw.renderers`** — Block and post renderers for markdown content.
|
||
`register(name, {type, pattern, render})`. Already used by mermaid, KaTeX,
|
||
CSV, and diff extensions.
|
||
|
||
**`lib.require()`** — Backend cross-package function calls. Starlark
|
||
packages call exported functions from library packages with the library's
|
||
own permission context.
|
||
|
||
The gap: `sw.slots` has no manifest awareness — the admin can't see which
|
||
extensions contribute where, install/uninstall can't warn about orphaned
|
||
contributions. `lib.require()` is restricted to `type: "library"` packages,
|
||
so a full package like an image generator can't export callable functions.
|
||
There are no conventions for slot naming or context contracts.
|
||
|
||
---
|
||
|
||
## Architecture
|
||
|
||
Three additions: manifest declarations, backend relaxation, and SDK helpers.
|
||
|
||
### 1. Manifest Declarations
|
||
|
||
#### Host Surfaces: `slots` Field
|
||
|
||
Surfaces declare named injection points with context descriptions:
|
||
|
||
```json
|
||
{
|
||
"id": "notes",
|
||
"slots": {
|
||
"toolbar-actions": {
|
||
"description": "Toolbar action buttons",
|
||
"context": {
|
||
"noteId": "string — current note ID",
|
||
"getContent": "function — returns note body text",
|
||
"setContent": "function — replaces note body text"
|
||
}
|
||
},
|
||
"note-footer": {
|
||
"description": "Content rendered below note body",
|
||
"context": {
|
||
"noteId": "string",
|
||
"content": "string — rendered HTML content"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
```json
|
||
{
|
||
"id": "chat",
|
||
"slots": {
|
||
"message-actions": {
|
||
"description": "Action buttons on individual messages",
|
||
"context": {
|
||
"messageId": "string",
|
||
"content": "string — message text",
|
||
"attachments": "array — [{url, type, name}]"
|
||
}
|
||
},
|
||
"image-actions": {
|
||
"description": "Action buttons overlaid on rendered images",
|
||
"context": {
|
||
"imageUrl": "string",
|
||
"messageId": "string",
|
||
"metadata": "object — generation params if available"
|
||
}
|
||
},
|
||
"composer-tools": {
|
||
"description": "Tool buttons in the message composer area",
|
||
"context": {
|
||
"conversationId": "string",
|
||
"insertText": "function — appends to composer"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
The `context` field is documentation, not enforcement. It tells extension
|
||
authors what data the slot provides. The kernel parses and stores slot
|
||
declarations but does not validate context shapes at runtime.
|
||
|
||
#### Contributing Extensions: `contributes` Field
|
||
|
||
Extensions declare which slots they inject into:
|
||
|
||
```json
|
||
{
|
||
"id": "note-dictate",
|
||
"title": "Note Dictation",
|
||
"type": "extension",
|
||
"tier": "browser",
|
||
"contributes": {
|
||
"notes:toolbar-actions": {
|
||
"label": "Dictate",
|
||
"icon": "🎤",
|
||
"description": "Voice-to-text note dictation"
|
||
}
|
||
},
|
||
"permissions": ["api.http"]
|
||
}
|
||
```
|
||
|
||
```json
|
||
{
|
||
"id": "image-gen",
|
||
"title": "Image Generator",
|
||
"type": "full",
|
||
"tier": "starlark",
|
||
"contributes": {
|
||
"chat:composer-tools": {
|
||
"label": "Generate Image",
|
||
"icon": "🎨",
|
||
"description": "AI image generation from text prompt"
|
||
}
|
||
},
|
||
"exports": ["generate", "list_models"],
|
||
"permissions": ["api.http", "connections.read", "files.write", "db.write"]
|
||
}
|
||
```
|
||
|
||
```json
|
||
{
|
||
"id": "image-edit",
|
||
"title": "Image Editor",
|
||
"type": "extension",
|
||
"tier": "starlark",
|
||
"contributes": {
|
||
"chat:image-actions": {
|
||
"label": "Edit Image",
|
||
"icon": "✏️",
|
||
"description": "Inpaint, outpaint, upscale, style transfer"
|
||
}
|
||
},
|
||
"exports": ["inpaint", "outpaint", "upscale", "restyle"],
|
||
"permissions": ["api.http", "connections.read", "files.write"]
|
||
}
|
||
```
|
||
|
||
**Slot naming convention:** `{host-package-id}:{slot-name}`. The colon
|
||
separates namespace from slot. Extensions contributing to `notes:toolbar-actions`
|
||
are declaring a relationship with the `notes` package.
|
||
|
||
#### What the Kernel Does with Declarations
|
||
|
||
**At install time:**
|
||
- Parse `slots` field → store in manifest (no separate table needed).
|
||
- Parse `contributes` field → validate that each target slot name follows
|
||
the `{pkg}:{slot}` convention. Do NOT validate that the host package
|
||
exists — contributing extensions can be installed before or after their
|
||
host. Soft coupling.
|
||
- Parse `exports` field → already stored in manifest. No changes needed.
|
||
|
||
**At admin display time:**
|
||
- `GET /api/v1/admin/packages/:id` includes `slots` and `contributes`
|
||
from manifest. Admin UI shows "This package provides X slots" and
|
||
"This package contributes to Y slots."
|
||
- New endpoint: `GET /api/v1/admin/slots` → returns a map of all declared
|
||
slots across installed packages with their contributors. Built by
|
||
scanning all package manifests — no new table.
|
||
|
||
**At uninstall time:**
|
||
- If uninstalling a package that declares `slots`, check if any enabled
|
||
packages declare `contributes` targeting those slots. Warn (not block):
|
||
"Uninstalling 'notes' will orphan contributions from: note-dictate,
|
||
note-ai." The admin decides.
|
||
- If uninstalling a contributing package, no warning needed — the slot
|
||
just has fewer entries.
|
||
|
||
---
|
||
|
||
### 2. Backend: `lib.require()` Relaxation
|
||
|
||
Currently `lib.require()` enforces `libPkg.Type == "library"`. This
|
||
prevents full packages (which have surfaces, settings, UI) from also
|
||
exporting callable functions.
|
||
|
||
**Change:** Replace the type check with an exports check.
|
||
|
||
In `sandbox/lib_module.go`, the current guard:
|
||
|
||
```go
|
||
if libPkg.Type != "library" {
|
||
return nil, fmt.Errorf("lib.require: package %q is type %q, not library", libraryID, libPkg.Type)
|
||
}
|
||
```
|
||
|
||
Becomes:
|
||
|
||
```go
|
||
exports := extractExports(libPkg.Manifest)
|
||
if len(exports) == 0 {
|
||
return nil, fmt.Errorf("lib.require: package %q declares no exports", libraryID)
|
||
}
|
||
```
|
||
|
||
Any package that declares `"exports": [...]` is callable via `lib.require()`,
|
||
regardless of type. The dependency check (`ListByConsumer`) remains
|
||
enforced — the consumer must still declare `"depends": ["image-gen"]`
|
||
in its manifest.
|
||
|
||
The `depends` field already accepts any package ID, not just libraries.
|
||
The `DependencyStore` has no type check. This change is one line in
|
||
`lib_module.go`.
|
||
|
||
**Security implication:** A full package's exported functions run with
|
||
that package's own permissions, same as libraries today. An image-gen
|
||
package with `api.http` + `files.write` permissions exposes `generate()`
|
||
— when llm-bridge calls it, it runs with image-gen's permissions, not
|
||
llm-bridge's. This is correct and already how `lib.require()` works.
|
||
|
||
---
|
||
|
||
### 3. SDK Additions
|
||
|
||
#### `sw.slots.renderAll(name, context)` Helper
|
||
|
||
Currently host surfaces must manually iterate slot entries:
|
||
|
||
```javascript
|
||
const entries = sw.slots.get('notes:toolbar-actions');
|
||
return entries.map(e => html`<${e.component} ...${ctx} />`);
|
||
```
|
||
|
||
Add a convenience helper:
|
||
|
||
```javascript
|
||
/**
|
||
* Render all components registered in a slot.
|
||
* @param {string} name — slot name
|
||
* @param {object} context — props passed to each component
|
||
* @returns {Array<VNode>} — array of rendered vnodes
|
||
*/
|
||
renderAll(name, context = {}) {
|
||
return this.get(name).map(e => {
|
||
try {
|
||
return e.component(context);
|
||
} catch (err) {
|
||
console.error(`[sw.slots] Error rendering "${e.id}" in slot "${name}":`, err);
|
||
return null;
|
||
}
|
||
}).filter(Boolean);
|
||
}
|
||
```
|
||
|
||
Host surface usage becomes:
|
||
|
||
```javascript
|
||
// In Notes toolbar:
|
||
html`<div class="notes-toolbar">
|
||
<button onclick=${save}>Save</button>
|
||
${sw.slots.renderAll('notes:toolbar-actions', {
|
||
noteId: currentNote.id,
|
||
getContent: () => editor.getValue(),
|
||
setContent: (text) => editor.setValue(text)
|
||
})}
|
||
</div>`
|
||
```
|
||
|
||
#### `sw.slots.declare(name, description)` (Optional)
|
||
|
||
Runtime declaration for slots that don't appear in the manifest (e.g.,
|
||
dynamically created slots). Mostly for discoverability — the debug panel
|
||
can list all active slots whether manifest-declared or runtime-declared.
|
||
|
||
```javascript
|
||
// In chat surface, when rendering an image:
|
||
sw.slots.declare('chat:image-actions', 'Action buttons on images');
|
||
```
|
||
|
||
Not enforced — `sw.slots.register()` works on any name regardless.
|
||
This is a documentation/debugging aid only.
|
||
|
||
---
|
||
|
||
## Composition Patterns
|
||
|
||
### Pattern A: LLM Tool Registration
|
||
|
||
LLM bridge exposes a tool registry. Tool extensions register at load time
|
||
via `lib.require()` on the backend and `sw.actions` on the frontend.
|
||
|
||
**llm-bridge (library) — script.star:**
|
||
```python
|
||
_tools = {}
|
||
|
||
def register_tool(name, description, parameters, handler):
|
||
"""Register a callable tool for LLM function calling."""
|
||
_tools[name] = {
|
||
"name": name,
|
||
"description": description,
|
||
"parameters": parameters,
|
||
"handler": handler,
|
||
}
|
||
|
||
def complete(messages, tools=None):
|
||
"""Send messages to LLM with registered tools available."""
|
||
tool_schemas = []
|
||
for t in _tools.values():
|
||
tool_schemas.append({
|
||
"name": t["name"],
|
||
"description": t["description"],
|
||
"parameters": t["parameters"],
|
||
})
|
||
# ... call LLM API with tool_schemas, handle tool_use responses
|
||
# by dispatching to _tools[name]["handler"]
|
||
```
|
||
|
||
**image-gen (full package) — script.star:**
|
||
```python
|
||
llm = lib.require("llm-bridge")
|
||
|
||
def generate(prompt, model="dall-e-3", size="1024x1024", seed=None):
|
||
conn = connections.get("openai")
|
||
# ... call API, store result in files module ...
|
||
return {"image_url": url, "prompt": prompt, "seed": actual_seed}
|
||
|
||
# Register as LLM tool at load time
|
||
llm.register_tool(
|
||
name="generate_image",
|
||
description="Generate an image from a text description",
|
||
parameters={"prompt": "string", "size": "string"},
|
||
handler=generate,
|
||
)
|
||
```
|
||
|
||
### Pattern B: UI Contribution (Toolbar Buttons)
|
||
|
||
**note-dictate (extension) — js/index.js:**
|
||
```javascript
|
||
sw.slots.register('notes:toolbar-actions', {
|
||
id: 'note-dictate',
|
||
priority: 200,
|
||
component: ({ noteId, setContent, getContent }) => {
|
||
const [recording, setRecording] = preact.useState(false);
|
||
|
||
const toggle = async () => {
|
||
if (recording) {
|
||
// Stop recording, send audio to STT api_route
|
||
const text = await sw.api.ext('note-dictate').post('/transcribe', {
|
||
audio: audioBlob
|
||
});
|
||
setContent(getContent() + '\n' + text.transcription);
|
||
setRecording(false);
|
||
} else {
|
||
// Start recording
|
||
setRecording(true);
|
||
}
|
||
};
|
||
|
||
return html`<button class="sw-btn sw-btn--ghost"
|
||
onclick=${toggle}
|
||
title="Dictate">
|
||
${recording ? '⏹' : '🎤'}
|
||
</button>`;
|
||
}
|
||
});
|
||
```
|
||
|
||
**notes (surface) — toolbar rendering:**
|
||
```javascript
|
||
html`<div class="notes-toolbar__actions">
|
||
<button onclick=${() => saveNote()}>💾</button>
|
||
<button onclick=${() => togglePin()}>📌</button>
|
||
${sw.slots.renderAll('notes:toolbar-actions', {
|
||
noteId: note.id,
|
||
getContent: () => editor.getValue(),
|
||
setContent: (text) => editor.setValue(text),
|
||
})}
|
||
</div>`
|
||
```
|
||
|
||
Notes doesn't know dictation exists. Dictation doesn't know Notes'
|
||
internals — just the slot contract (noteId, getContent, setContent).
|
||
|
||
### Pattern C: Image Actions (Generate + Edit + Upscale)
|
||
|
||
Three independent extensions contribute to the same image display slot:
|
||
|
||
**chat surface — image rendering:**
|
||
```javascript
|
||
function ChatImage({ src, messageId, metadata }) {
|
||
return html`<div class="chat-image">
|
||
<img src=${src} />
|
||
<div class="chat-image__actions">
|
||
${sw.slots.renderAll('chat:image-actions', {
|
||
imageUrl: src,
|
||
messageId,
|
||
metadata,
|
||
})}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
```
|
||
|
||
**image-gen — contributes regen button:**
|
||
```javascript
|
||
sw.slots.register('chat:image-actions', {
|
||
id: 'image-gen:regenerate',
|
||
priority: 100,
|
||
component: ({ imageUrl, metadata }) => {
|
||
const regen = async () => {
|
||
// Open prompt editor with original params
|
||
const params = await sw.prompt('Edit prompt', {
|
||
default: metadata?.prompt || '',
|
||
multiline: true,
|
||
});
|
||
if (!params) return;
|
||
const result = await sw.api.ext('image-gen').post('/generate', {
|
||
prompt: params,
|
||
seed: metadata?.seed,
|
||
negative_prompt: metadata?.negative_prompt,
|
||
});
|
||
// result triggers a new chat message with the generated image
|
||
};
|
||
|
||
return html`<button class="sw-btn sw-btn--sm" onclick=${regen} title="Regenerate">
|
||
🔄
|
||
</button>`;
|
||
}
|
||
});
|
||
```
|
||
|
||
**image-edit — contributes edit drawer:**
|
||
```javascript
|
||
sw.slots.register('chat:image-actions', {
|
||
id: 'image-edit:edit',
|
||
priority: 200,
|
||
component: ({ imageUrl, metadata }) => {
|
||
const openEditor = () => {
|
||
// Open a drawer with editing options
|
||
sw.emit('drawer.open', {
|
||
title: 'Edit Image',
|
||
component: ImageEditPanel,
|
||
props: { imageUrl, metadata },
|
||
});
|
||
};
|
||
|
||
return html`<button class="sw-btn sw-btn--sm" onclick=${openEditor} title="Edit">
|
||
✏️
|
||
</button>`;
|
||
}
|
||
});
|
||
```
|
||
|
||
**image-upscale — contributes upscale button:**
|
||
```javascript
|
||
sw.slots.register('chat:image-actions', {
|
||
id: 'image-upscale:upscale',
|
||
priority: 300,
|
||
component: ({ imageUrl }) => {
|
||
const upscale = async () => {
|
||
const result = await sw.api.ext('image-upscale').post('/upscale', {
|
||
image_url: imageUrl,
|
||
scale: 2,
|
||
});
|
||
// Display or replace with upscaled image
|
||
};
|
||
|
||
return html`<button class="sw-btn sw-btn--sm" onclick=${upscale} title="Upscale 2×">
|
||
🔍
|
||
</button>`;
|
||
}
|
||
});
|
||
```
|
||
|
||
Result: an image in chat gets three action buttons from three separate
|
||
extensions. Install one, get one button. Install all three, get all three.
|
||
Uninstall one, the others keep working. The chat surface has no knowledge
|
||
of any of them.
|
||
|
||
### Pattern D: LLM Note Restructuring
|
||
|
||
Combines backend composability (lib.require) with frontend contribution
|
||
(slots):
|
||
|
||
**note-ai (extension) — manifest.json:**
|
||
```json
|
||
{
|
||
"id": "note-ai",
|
||
"type": "extension",
|
||
"tier": "starlark",
|
||
"depends": ["llm-bridge"],
|
||
"contributes": {
|
||
"notes:toolbar-actions": {
|
||
"label": "AI Restructure",
|
||
"icon": "✨"
|
||
}
|
||
},
|
||
"permissions": ["api.http"],
|
||
"settings": {
|
||
"restructure_prompt": {
|
||
"type": "string",
|
||
"label": "Restructure Prompt",
|
||
"description": "System prompt for AI note restructuring",
|
||
"default": "Restructure the following note into clear sections with headers. Preserve all information. Use markdown formatting.",
|
||
"user_overridable": true
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**note-ai — script.star (api_route handler):**
|
||
```python
|
||
llm = lib.require("llm-bridge")
|
||
|
||
def on_request(req):
|
||
if req["method"] == "POST" and req["path"] == "/restructure":
|
||
content = req["body"]["content"]
|
||
prompt = settings.get("restructure_prompt")
|
||
result = llm.complete([
|
||
{"role": "system", "content": prompt},
|
||
{"role": "user", "content": content},
|
||
])
|
||
return {"status": 200, "body": {"restructured": result["text"]}}
|
||
```
|
||
|
||
**note-ai — js/index.js:**
|
||
```javascript
|
||
sw.slots.register('notes:toolbar-actions', {
|
||
id: 'note-ai:restructure',
|
||
priority: 500,
|
||
component: ({ noteId, getContent, setContent }) => {
|
||
const [loading, setLoading] = preact.useState(false);
|
||
|
||
const restructure = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const result = await sw.api.ext('note-ai').post('/restructure', {
|
||
content: getContent()
|
||
});
|
||
setContent(result.restructured);
|
||
sw.toast('Note restructured', 'success');
|
||
} catch (e) {
|
||
sw.toast('Restructure failed: ' + e.message, 'error');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
return html`<button class="sw-btn sw-btn--ghost"
|
||
onclick=${restructure}
|
||
disabled=${loading}
|
||
title="AI Restructure">
|
||
${loading ? '⏳' : '✨'}
|
||
</button>`;
|
||
}
|
||
});
|
||
```
|
||
|
||
The user configures their restructuring prompt in Settings. The button
|
||
appears in Notes toolbar. Clicking it sends the note content to the
|
||
api_route, which calls llm-bridge, which calls the configured LLM provider.
|
||
Four packages involved (notes, note-ai, llm-bridge, the LLM connection),
|
||
zero hardcoded dependencies between surfaces.
|
||
|
||
---
|
||
|
||
## Kernel Changes
|
||
|
||
### Modified Files
|
||
|
||
| File | Change |
|
||
|------|--------|
|
||
| `sandbox/lib_module.go` | Replace type check with exports check (~1 line) |
|
||
| `handlers/extensions.go` | Parse `contributes` and `slots` manifest fields (validation) |
|
||
| `handlers/packages.go` | Uninstall warning for orphaned contributions |
|
||
| `src/js/sw/sdk/slots.js` | Add `renderAll(name, context)` and `declare(name, desc)` |
|
||
| `docs/PACKAGE-FORMAT.md` | Document `slots`, `contributes`, `exports` fields |
|
||
| `docs/EXTENSION-GUIDE.md` | Composability patterns section |
|
||
|
||
### New Files
|
||
|
||
| File | Purpose |
|
||
|------|---------|
|
||
| `handlers/admin_slots.go` | `GET /admin/slots` aggregation endpoint |
|
||
|
||
### No New:
|
||
|
||
- Database tables
|
||
- Migrations
|
||
- Starlark modules
|
||
- Permission constants
|
||
- Config env vars
|
||
|
||
This is deliberately small. The runtime infrastructure exists. The
|
||
design adds manifest-level visibility, one backend guard relaxation,
|
||
and one SDK helper. The composability comes from conventions and
|
||
documentation, not kernel complexity.
|
||
|
||
---
|
||
|
||
## Slot Catalog (Initial)
|
||
|
||
These are the recommended slots for first-party packages. Extension
|
||
authors may define additional slots following the naming convention.
|
||
|
||
| Slot | Host | Context | Use Case |
|
||
|------|------|---------|----------|
|
||
| `notes:toolbar-actions` | notes | noteId, getContent, setContent | Dictation, AI tools, formatting |
|
||
| `notes:note-footer` | notes | noteId, content | Related items, AI summary, metadata |
|
||
| `chat:composer-tools` | chat | conversationId, insertText | Image gen, file attach, slash commands |
|
||
| `chat:message-actions` | chat | messageId, content, attachments | Reactions, translate, bookmark |
|
||
| `chat:image-actions` | chat | imageUrl, messageId, metadata | Regen, edit, upscale, style transfer |
|
||
| `schedules:event-actions` | schedules | eventId, event | Add to calendar, share, convert to task |
|
||
| `admin:package-actions` | admin | packageId, package | Custom admin tools per package |
|
||
|
||
Each host surface adds `sw.slots.renderAll()` calls at the appropriate
|
||
locations. Extensions contribute via `sw.slots.register()` in their JS.
|
||
The manifest `contributes` field makes the relationship visible to admins.
|
||
|
||
---
|
||
|
||
## Future Considerations
|
||
|
||
- **Slot schema validation.** Currently context contracts are documentation
|
||
only. A runtime assertion mode (dev only) could warn when a contributor
|
||
receives unexpected props. Deferred — convention is sufficient pre-1.0.
|
||
|
||
- **Slot visibility controls.** An admin might want to disable a specific
|
||
contribution without disabling the entire contributing extension. A
|
||
per-contribution enable/disable toggle in the admin UI. Deferred —
|
||
extension enable/disable is the current granularity.
|
||
|
||
- **Cross-surface slot contributions.** An extension contributing to
|
||
`notes:toolbar-actions` AND `chat:message-actions` with the same
|
||
underlying logic but different UI. The `contributes` field already
|
||
supports multiple entries. The extension's JS registers into both
|
||
slots with slot-appropriate components.
|
||
|
||
- **Backend event subscriptions between extensions.** Currently extensions
|
||
react to kernel events via `hooks`. Extension-to-extension events
|
||
(e.g., "image-gen completed" → "chat refreshes") flow through the
|
||
existing event bus — the generating extension's api_route publishes
|
||
via `realtime.publish()`, the consuming surface listens via
|
||
`sw.realtime.subscribe()`. No new primitive needed.
|