# 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} — 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`
${sw.slots.renderAll('notes:toolbar-actions', { noteId: currentNote.id, getContent: () => editor.getValue(), setContent: (text) => editor.setValue(text) })}
` ``` #### `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``; } }); ``` **notes (surface) — toolbar rendering:** ```javascript html`
${sw.slots.renderAll('notes:toolbar-actions', { noteId: note.id, getContent: () => editor.getValue(), setContent: (text) => editor.setValue(text), })}
` ``` 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`
${sw.slots.renderAll('chat:image-actions', { imageUrl: src, messageId, metadata, })}
`; } ``` **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``; } }); ``` **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``; } }); ``` **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``; } }); ``` 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``; } }); ``` 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.