This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/docs/DESIGN-extension-composability.md
Jeffrey Smith 68713bf539
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 5s
CI/CD / test-runners (pull_request) Has been skipped
CI/CD / e2e-smoke (pull_request) Has been skipped
CI/CD / test-frontend (pull_request) Successful in 6s
CI/CD / test-go-pg (pull_request) Successful in 2m42s
CI/CD / test-sqlite (pull_request) Successful in 2m58s
CI/CD / build-and-deploy (pull_request) Successful in 1m33s
v0.8.5: Extension composability — slots, contributes, lib.require relaxation
Manifest declarations for slots (host surfaces declare injection points)
and contributes (extensions declare UI contributions). lib.require()
relaxed from library-only to any package with exports. Admin slots
aggregation endpoint. SDK renderAll() helper. 7 new tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:14:04 +00:00

22 KiB
Raw Blame History

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:

{
  "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 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:

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-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.