Land two design documents from planning sessions: - DESIGN-storage-primitives.md (files module, workspace, capability negotiation, vector columns) - DESIGN-extension-composability.md (slots/contributes manifest fields, lib.require relaxation, SDK helpers) Expand ROADMAP.md with detailed v0.8.x-v1.0 plan: storage primitives, reference extensions, sidecar tier, stable release gate criteria. Merge design decisions from both planning docs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
22 KiB
DESIGN — Extension Composability
Version: v0.8.4 Status: Draft 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:
{
"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"
}
}
}
}
{
"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:
{
"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"]
}
{
"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"]
}
{
"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
slotsfield → store in manifest (no separate table needed). - Parse
contributesfield → 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
exportsfield → already stored in manifest. No changes needed.
At admin display time:
GET /api/v1/admin/packages/:idincludesslotsandcontributesfrom 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 declarecontributestargeting 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:
if libPkg.Type != "library" {
return nil, fmt.Errorf("lib.require: package %q is type %q, not library", libraryID, libPkg.Type)
}
Becomes:
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:
const entries = sw.slots.get('notes:toolbar-actions');
return entries.map(e => html`<${e.component} ...${ctx} />`);
Add a convenience helper:
/**
* 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:
// 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.
// 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:
_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:
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:
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:
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:
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:
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:
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:
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:
{
"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):
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:
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-actionsANDchat:message-actionswith the same underlying logic but different UI. Thecontributesfield 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 viarealtime.publish(), the consuming surface listens viasw.realtime.subscribe(). No new primitive needed.