Skip to content
OpenWaggle

Develop extensions

Build a local extension package that adds desktop UI, commands, or agent tools.

Build an OpenWaggle extension when you need to add desktop UI, commands, or agent tools. An extension is a local package with a manifest and declared code files. For reusable instructions without new code, use a skill instead.

If you only want to use an existing package, start with Install extensions. This page is the developer reference for the manifest, mount(context) entry point, SDK permissions, and package lifecycle.

Start with the example below, then follow the lifecycle checklist to load it locally. Later sections cover additional contribution types and runtime behavior. An extension cannot add an unsupported host capability by declaring it; that requires an OpenWaggle update.

Basic extension how-to

A basic extension is a directory with an openwaggle.extension.json manifest and one or more declared runtime files.

For a project-local install, place it under the project:

my-project/
  .openwaggle/
    extensions/
      example-extension/
        openwaggle.extension.json
        package.json
        src/
          settings.js
          side-panel.js
        modules/
          settings.js
          side-panel.js
        docs/
          README.md

The directory name must match the manifest id. Global extensions are discovered from OpenWaggle’s app-data extensions/ directory and should be managed through Settings > Extensions or the approved package workflow, because they can affect every project where they are enabled.

Create the manifest first:

{
  "manifestVersion": 1,
  "id": "example-extension",
  "name": "Example Extension",
  "version": "0.1.0",
  "description": "A small extension with settings and a side panel.",
  "sdk": {
    "openwaggle": ">=0.1.0 <0.2.0"
  },
  "sourceFiles": ["package.json", "src/settings.js", "src/side-panel.js"],
  "builtArtifacts": ["package.json", "modules/settings.js", "modules/side-panel.js"],
  "install": {
    "source": "prebuilt"
  },
  "capabilities": [
    {
      "id": "openwaggle.storage",
      "methods": ["get", "set", "list"],
      "scopes": ["project"]
    }
  ],
  "contributions": {
    "settingsSections": [
      {
        "id": "example.settings",
        "title": "Example Settings",
        "runtime": "federated-module",
        "execution": "host-renderer",
        "entry": "modules/settings.js",
        "capability": "openwaggle.storage",
        "methods": ["get", "set", "list"]
      }
    ],
    "sidePanels": [
      {
        "id": "example.panel",
        "title": "Example Panel",
        "icon": "ticket",
        "runtime": "federated-module",
        "execution": "host-renderer",
        "entry": "modules/side-panel.js",
        "capability": "openwaggle.storage",
        "methods": ["get", "list"]
      }
    ],
    "sessionSummarySections": [
      {
        "id": "example.session-summary",
        "title": "Example Session Status",
        "placement": "details",
        "rows": [
          { "id": "status", "label": "Status", "value": "Ready" },
          {
            "id": "open-details",
            "label": "Open details",
            "action": { "family": "sidePanels", "contributionId": "example.panel" }
          }
        ]
      }
    ]
  }
}

Then build or copy the entry modules listed in builtArtifacts. A federated-module entry exports mount(context):

export async function mount(context) {
  const root = document.createElement('section')
  root.textContent = `Mounted ${context.contribution.id}`
  context.root.append(root)

  return () => {
    root.remove()
  }
}

Lifecycle checklist

Use this checklist for a basic package:

  1. Create a complete package proposal with the manifest and every file that should exist in the package directory.
  2. Choose the package scope. Project-local packages live under <project>/.openwaggle/extensions/<extension-id>/. Global packages live under OpenWaggle app data extensions/<extension-id>/ and affect every project unless a project opts out.
  3. For agent-created or agent-updated packages, approve the exact proposal before OpenWaggle writes files. Approval is tied to the extension id, scope, operation, file paths, and file contents. Global package writes require a second global-impact confirmation.
  4. Ensure openwaggle.extension.json lists every source file, built artifact, capability, network origin, runtime requirement, Pi resource root, docs topic, and contribution.
  5. Build the package. If install.source is prebuilt, ship the built files already present in builtArtifacts. If install.source is local-build, declare build.command and build.outputs, then use Settings > Extensions to approve and run the build.
  6. Install the package by writing it to the chosen extension root through Settings > Extensions or the approved package workflow. Manual project-local development can place files directly under .openwaggle/extensions/<extension-id>/, but user-local trust, enablement, build approval, storage, and project opt-outs are not committed with the package.
  7. Open Settings > Extensions and refresh discovery.
  8. Inspect the package path, SDK range, content hash, install source, build command, capabilities, network origins, trusted local code, runtime requirements, docs topics, Pi resource roots, and diagnostics.
  9. Trust the extension. Trust pins the current package identity, SDK range, version, granted privileges, and content hash.
  10. Enable the extension.
  11. Reload the extension registry so eligible contributions can appear on their surfaces.
  12. Update the extension by replacing the package with a new approved proposal and bumping the version. OpenWaggle treats the changed content hash as an explicit update; approve the update, then enable and reload again if the update flow disables runtime loading.
  13. Disable the extension from Settings > Extensions to stop all contributions without deleting files. This unregisters contributions, tears down mounted sandbox frames and runtime subscriptions as surfaces unmount, revokes module access, and marks the package as not reloaded. For global extensions, use project availability controls to disable only one project.
  14. Remove the extension from Settings > Extensions by choosing Remove and approving the confirmation. Project-local removal deletes <project>/.openwaggle/extensions/<extension-id>/; global removal deletes the app-data extensions/<extension-id>/ package and requires explicit global-impact confirmation. The approved remove workflow unregisters contributions, tears down runtime/module access, deletes lifecycle trust and enablement pins, and removes the package directory. Extension-owned storage cleanup should be a separate explicit user choice when data deletion matters.
  15. Agents must not delete extension package directories directly as a shortcut. They should propose the removal, get user approval for the exact extension id and scope, then call the host remove workflow so OpenWaggle can perform lifecycle cleanup before the files disappear.

Model

OpenWaggle is manifest-first. The manifest is the contract the host reads before running extension code. If a contribution, capability, file, network origin, runtime requirement, or build step is not declared in the manifest, OpenWaggle should not treat it as available at runtime.

Static manifest contributions remain the simplest public author path. Runtime code can also call context.sdk.runtime.registerContribution(scope, registration) and context.sdk.runtime.unregisterContribution(scope, unregistration) through the broker to add or remove contributions without an app restart. The manifest must declare the openwaggle.runtime capability with register-contribution and/or unregister-contribution, and it must declare the contribution families that runtime code may use, even if those family arrays start empty.

Dynamic registration uses the same contribution schemas as the manifest. Runtime code cannot request new capabilities, methods, or scopes beyond the manifest declaration, cannot replace static manifest contributions, and cannot unregister static manifest contributions. Project and session scoped runtime calls are constrained to that invocation scope so a project-scoped dynamic contribution does not leak into another project. Branch-scoped dynamic registrations fail closed until branch targets are represented in the contribution registry.

An extension package can declare multiple contribution families:

  • settingsSections for Settings content owned by the extension.
  • sidePanels for right-side or auxiliary panels.
  • dialogs for host-owned modals and dialog content.
  • routes for extension-owned route content inside an OpenWaggle route container.
  • transcriptRenderers for durable transcript rendering.
  • toolRenderers for Pi-native tool call/result cards.
  • customMessageRenderers for Pi custom message records.
  • interactionRenderers for Pi interaction requests such as confirm, select, input, editor, notify, and typed custom interactions.
  • statusWidgets for compact status surfaces.
  • sessionSummarySections for session-scoped information and actions in the floating Session Summary.
  • commands and slashCommands for slash command menu and composer-adjacent launchers.

OpenWaggle owns the container: placement, chrome, sizing, docking, fallback behavior, and persistence rules. The extension owns the content mounted inside that container.

Visual contributions use the federated module runtime. The extension exports mount(context) from its entry module. The module can use React, Vue, Preact, Svelte, plain DOM, or another UI stack. The required contract is the mount context, not a framework.

Composer-adjacent contributions are compact actions, slash commands, or launchers. They can open host-owned dialogs, side panels, or interaction surfaces, but they must not inject arbitrary input controls into the composer text flow.

Extensions add to or augment OpenWaggle-owned surfaces. They cannot replace the core shell layout, replace global navigation, or install app-wide OpenWaggle themes. Use context.theme and scoped extension styles for mounted content only.

Contribution surfaces at a glance

Choose the surface by the job the extension is doing, not by the framework used to render it.

  • settingsSections are for extension configuration, account setup, feature toggles, and diagnostics that belong in Settings. They should read and write settings through brokered SDK capabilities.
  • sidePanels are for auxiliary workspace content that benefits from staying open while the user works, such as issue lists, project metadata, logs, or package state shared with other contributions.
  • dialogs are for focused host-owned modals, confirmations, pickers, and forms that should temporarily interrupt the current workflow.
  • routes are for larger extension-owned views mounted inside an OpenWaggle route container. They should not replace core shell navigation.
  • commands and slashCommands launch actions from the slash command menu or composer. Composer contributions are compact launchers or selectors, not arbitrary controls inside the composer text input.
  • transcriptRenderers render durable Pi session records in the chat transcript. They must be reconstructable from the mount context and Pi session data after remount or restart.
  • toolRenderers render Pi-native tool calls and results. They customize desktop presentation for Pi tools; they do not create a separate OpenWaggle tool runtime.
  • customMessageRenderers render Pi custom message records while preserving the Pi-native custom message type as the binding identity.
  • interactionRenderers collect feedback for pending Pi interactions such as confirm, select, input, editor, notify, or typed custom interactions, then return the typed response through the SDK.
  • statusWidgets are compact status surfaces for live progress, connection state, or extension-owned indicators.
  • sessionSummarySections augment the opened session’s floating Summary with host-rendered declarative rows. They appear in the opened Summary when their package and session scope are eligible, and disappear with the Summary while the right sidebar is open. Rows can display a value, badge, count, or session resource, and can reference a separately declared command, dialog, or side panel from the same extension package. They must not display authorization controls, infer another session, or replace host-owned Environment, Hive, Outputs, or Sources sections.

The same extension can contribute to multiple surfaces. Shared package state can coordinate those live surfaces, while the transcript remains the durable audit trail for agent-loop activity.

Side panel icons

A side panel can declare an optional icon, which OpenWaggle shows on the Panel rail and in All panels. Only sidePanels accept it. Use one of two forms:

  • A bundled Lucide icon name in kebab-case, such as "ticket" or "git-pull-request". OpenWaggle checks the name against the Lucide version it ships.
  • { "svg": "assets/panel-icon.svg" }, a path to an SVG file inside the extension package. The path must be relative to the package root (no leading / or \ and no drive letter), must not contain empty, . or .. segments, must not have leading or trailing whitespace or NUL bytes, must be at most 260 characters, and must end in .svg.
{
  "sidePanels": [
    {
      "id": "example.board",
      "title": "Board",
      "icon": { "svg": "assets/board.svg" },
      "runtime": "federated-module",
      "execution": "host-renderer",
      "entry": "modules/board.js"
    }
  ]
}

OpenWaggle draws only the icon’s shape and paints it in the rail’s own colours, the way it draws built-in icons, so colours and gradients in the file are ignored. Draw a single-colour shape on a transparent background with a viewBox; 24 × 24 matches Lucide. Keep the file under 32 KiB. OpenWaggle reads it once per installed package content and keeps only shapes. It rejects a file with <script> or <foreignObject> elements, characters XML does not allow, or no shapes left after cleaning. It drops <style> elements, style and class attributes, event handlers, external href references, embedded images and attribute values that name any resource other than a url(#id) inside the file. The SVG file counts as package content: editing it changes the package’s content hash like any other package file.

A side panel registered at runtime with context.sdk.runtime.registerContribution may use a Lucide name, or an SVG file that a side panel in the manifest already declares. Only manifest-declared SVG files are part of the content hash, so a registration that names any other SVG file fails, as does a registration whose icon does not match the icon schema.

When the icon is missing, names an unknown Lucide icon or points to an unusable file, the panel shows a letter tile with the first letter of its title, and Settings › Extensions lists a panel-icon-invalid warning under the contribution registry diagnostics.

Session Summary sections and resources

sessionSummarySections are declarative. OpenWaggle renders their title, state, disclosure, rows, resource links, and actions so the floating layout, keyboard behavior, and failure isolation stay consistent with core sections. Read Session Summary and Resources before choosing this surface. The Summary is available when the opened session has messages, Hive relationships, or workspace activity such as managed Actions or preparation. It never reserves chat width.

A section can declare:

  • placement: context, coordination, or details;
  • state: ready, loading, live, or failure, with an optional status message;
  • disclosure: the initial expanded state, whether the section is collapsible, and an optional auto-collapse delay from 1,000 to 300,000 milliseconds;
  • rows with a value, badge, count, resource id, or action targeting a command, dialog, or side panel from the same package. A row can target one resource or one action, but not both; use separate rows when both destinations matter.

Choose placement by the information’s relationship to the session. context sections appear after Environment, Actions, Workspace preparation, and change-request information but before Hive. coordination sections follow Hive. details, the default, appear after Sources. OpenWaggle controls the order inside each placement and does not let extensions move or replace core sections.

The host hides an empty ready section. A loading, live, or failure state remains visible without rows so the user can understand what the extension is doing. Expansion is remembered per session and installed package content. Transient dialogs and pending actions reset when the opened session changes, and one extension failure does not affect the rest of the Summary.

An action must point to a commands, dialogs, or sidePanels contribution declared by the same package under the exact family and id. A command also needs an executable capability and method binding. OpenWaggle rejects a Summary section with a missing, mis-typed, ineligible, or out-of-scope action target and reports a contribution diagnostic instead of leaving a dead row in the panel.

Commands run through the broker using the narrowest eligible declared scope. The host prefers session, then project, then app scope. A dialog launched from a Summary row receives context.sessionId and this context.surface.payload:

{
  "surface": "session-summary",
  "sessionId": "the-opened-session-id",
  "projectPaths": ["/path/to/project"],
  "messageCount": 12
}

Treat these host values as context, not as reusable authority. The broker still validates the contribution, package identity, project, session, capability, and method for each call. A side panel resolves again against the opened session when the right sidebar mounts.

An executable contribution can also publish durable Sources or Outputs through context.sdk.openWaggle.resources. Declare the capability on the package and on that executable contribution:

{
  "capabilities": [
    {
      "id": "openwaggle.resources",
      "methods": ["publish-resource", "list-resources"],
      "scopes": ["session"]
    }
  ],
  "contributions": {
    "sidePanels": [
      {
        "id": "example.results",
        "title": "Example Results",
        "runtime": "federated-module",
        "execution": "host-renderer",
        "entry": "modules/results.js",
        "capability": "openwaggle.resources",
        "methods": ["publish-resource", "list-resources"]
      }
    ],
    "sessionSummarySections": [
      {
        "id": "example.session-summary",
        "title": "Example Results",
        "placement": "details",
        "disclosure": { "defaultExpanded": true },
        "state": { "status": "live", "message": "Watching checks" },
        "rows": [
          {
            "id": "open-results",
            "label": "Open results",
            "action": { "family": "sidePanels", "contributionId": "example.results" }
          }
        ]
      }
    ]
  }
}

Use the session id and project path supplied by the mounted host surface. Do not accept either value from extension-controlled input:

import type { OpenWaggleExtensionMountContext } from '@openwaggle/extension-sdk'

export async function publishCoverage(context: OpenWaggleExtensionMountContext) {
  const projectPath = context.projectPaths[0]
  if (!context.sessionId || !projectPath) return

  const scope = {
    kind: 'session' as const,
    projectPath,
    sessionId: context.sessionId,
  }
  const published = await context.sdk.openWaggle.resources.publish(scope, {
    key: 'coverage-report',
    kind: 'link',
    title: 'Coverage report',
    role: 'output',
    locator: 'https://reports.example.com/coverage',
  })
  if (!published.ok) return published

  return context.sdk.openWaggle.resources.list(scope)
}

Reuse a stable key within the extension contribution. Publishing the same key, kind, role, and normalized locator is idempotent. Version one accepts only credential-free HTTPS image and link locators with a source or output role. list(scope, { limit, cursor }) accepts a limit from 1 to 100 and returns a bounded operation result. value.resources contains display metadata only, value.total is the exact session total, and value.nextCursor continues the stable page. Original locators, managed paths, occurrence history, and canonical keys remain private to the host. The broker binds both methods to the mounted contribution’s project and session, so changing the scope payload cannot expose another open or archived session.

Use the resource id returned by publish or list in a Summary row’s resourceId. The host opens a viewable image in the session gallery and other resources in their owning Sources or Outputs view. An id from another session, a deleted resource, or an invented id stays unavailable. List or publish again and replace the dynamic section instead of caching resource ids across sessions.

For changing status, register a runtime sessionSummarySections contribution in session scope. Registering the same family, contribution id, and scope replaces the previous dynamic value, so a Worker count or check state can change without restarting OpenWaggle. Unregister it during cleanup when the information no longer applies. The host pins session-scoped registrations to the invoking project and session even if the contribution omits target; attempts to name another target fail. Branch-scoped runtime registration is not supported yet.

Resource publishing does not fetch an image just because the extension lists it or the Summary becomes visible. The user authorizes the guarded HTTPS fetch by opening the image. If the fetch or managed copy fails, OpenWaggle presents a retry state in its own resource UI. Extensions should keep the same stable key and locator for a retry rather than publishing duplicates.

Visual runtimes, SDK context, and theme

Use the federated-module runtime for visual contributions. OpenWaggle may implement that runtime with module federation, import maps, versioned runtime URLs, or another loader, but extension authors target the same framework-neutral mount(context) entry point.

OpenWaggle can represent trusted.renderer as privileged manifest metadata. runtime: "trusted-renderer" visual contributions are mounted through OpenWaggle’s sandboxed extension frame boundary, not imported into the app renderer global, so they use the same brokered SDK/context path as federated-module contributions instead of direct renderer globals, writable stores, Electron IPC helpers, or Pi SDK internals.

mount(context) receives the only OpenWaggle objects a visual contribution should use:

  • context.root: the host-owned DOM root where the extension attaches content.
  • context.contribution: package id, contribution id, contribution family, and manifest-declared metadata for the mounted contribution.
  • context.surface: surface-specific data such as the active settings section, side panel container, transcript record, tool event, or pending interaction.
  • context.sdk: typed capability calls for package storage, selected OpenWaggle state/actions/settings/docs discovery, runtime contribution registration/unregistration, and surface behavior such as sending surface actions or responding to pending interactions.
  • context.theme: semantic host theme data, including tokens and CSS-variable-shaped values for color, typography, spacing, radius, focus, and shadow.

Use theme tokens from context.theme instead of importing OpenWaggle CSS internals or hard-coding app colors. The host owns token values and can adapt them to user settings, high-contrast modes, and future themes. The extension owns only its mounted content and should keep styles scoped to that content.

The SDK/context boundary is also the safety boundary. A renderer module can request brokered actions through context.sdk, but it must not import writable OpenWaggle stores, renderer feature files, Electron IPC helpers, or Pi SDK internals.

Optional shared author modules

The required runtime contract is still mount(context). OpenWaggle also exposes framework-neutral shared author modules for extensions that want typed helpers:

  • extension-sdk exports the broker SDK factory, operation result types, storage helpers, OpenWaggle state/actions/settings/docs helpers, and the public OpenWaggleExtensionMountContext / OpenWaggleFederatedModule types.
  • extension-theme exports semantic theme tokens, CSS variable names, fallback token creation, and helpers for serializing host theme values.
  • extension-ui exports framework-neutral class names, data attributes, a class-name join helper, and a small CSS stylesheet generator built from OpenWaggle theme variables.

These modules are plain TypeScript and DOM helpers. They do not import React, React DOM, renderer feature code, Zustand stores, Electron IPC helpers, or Pi SDK internals.

The modules are optional. Extension modules can ignore them and use only the objects passed in context. If an extension build consumes the helpers, keep them bundled into the extension artifact or resolve them through the OpenWaggle-provided author-module mechanism for that installed SDK version. Do not import source paths from an arbitrary OpenWaggle checkout as a runtime dependency for a distributed extension.

For npm install commands, package entry points, and concrete import examples, see Packages, especially @openwaggle/extension-sdk and @openwaggle/extension-react.

Plain DOM example:

import {
  createOpenWaggleExtensionUiStylesheet,
  OPENWAGGLE_EXTENSION_UI_CLASS_NAMES as ui,
} from '@openwaggle/extension-sdk/ui'

export async function mount(context) {
  const style = document.createElement('style')
  style.textContent = createOpenWaggleExtensionUiStylesheet({
    theme: context.theme,
  })

  const panel = document.createElement('section')
  panel.className = `${ui.root} ${ui.panel} ${ui.stack}`

  const heading = document.createElement('h2')
  heading.className = ui.heading
  heading.textContent = context.contribution.title

  const body = document.createElement('p')
  body.className = ui.text
  body.textContent = `Mounted for ${context.extension.name}`

  panel.append(heading, body)
  context.root.append(style, panel)

  return () => {
    style.remove()
    panel.remove()
  }
}

The example uses the published @openwaggle/extension-sdk/ui export. Install a compatible SDK version and bundle the helper code into your extension artifact. First-party fixtures in this repository use the internal @shared/extension-ui alias; distributed extensions must not depend on that checkout alias. Runtime compatibility still depends on the mount context and brokered SDK capability contract.

How an extension appears on screen

OpenWaggle decides where the extension appears, sizes its container, and checks which APIs it can call. The extension renders its own UI inside that container.

flowchart TD
  A["1. User installs or edits an extension package"] --> B["2. OpenWaggle reads the manifest"]
  B --> C["3. OpenWaggle checks files, SDK range, paths, hashes, and lifecycle state"]
  C --> D{"Does it need a local build?"}
  D -- "Yes" --> E["4a. User approves the build"]
  E --> F["4b. OpenWaggle runs build.command and checks build outputs"]
  D -- "No" --> G["5. User trusts, enables, and reloads the extension"]
  F --> G
  G --> H["6. OpenWaggle builds the contribution registry"]
  H --> I["7. A surface asks for the contribution: settings, side panel, transcript, tool card, dialog, etc."]
  I --> J["8. OpenWaggle creates the owned iframe/container"]
  J --> K["9. The iframe imports the extension module"]
  K --> L["10. OpenWaggle calls mount(context)"]
  L --> M["11. The extension renders UI; OpenWaggle observes mounted state and size"]

The important split is:

  • OpenWaggle decides whether the extension is allowed to render.
  • OpenWaggle creates and sizes the container.
  • The extension decides what to render inside mount(context).
  • The extension can render immediately or show its own skeleton while it does async work.

Readiness and loading

An extension contribution is ready to be rendered only after all lifecycle checks pass:

  • manifest schema is valid
  • referenced files exist
  • SDK range is compatible
  • content hash matches the trusted pin
  • local build is approved and succeeded, when required
  • extension is trusted
  • extension is enabled
  • extension has been reloaded after the last trust, enable, build, or update change
  • project opt-outs do not block the current project

OpenWaggle builds the contribution registry from that state. The registry is the menu of extension contributions that the app can actually use. If a contribution is not in the registry, the UI treats it as unavailable and uses an OpenWaggle-owned fallback when one exists.

Mount readiness is separate from registry readiness:

  • Registry readiness means OpenWaggle knows the contribution is allowed and available.
  • Mount readiness means the iframe loaded the module and the module’s mount(context) function resolved.

OpenWaggle shows a generic mounting state only until mount(context) resolves. If an extension needs to fetch data, call storage, or wait for a network API, it should render a lightweight shell or skeleton first and continue the async work after the initial render.

Local builds

Most extensions can ship already-built JavaScript in builtArtifacts. Those extensions do not compile at render time.

Extensions that need a local build declare it in the manifest:

{
  "install": {
    "source": "local-build"
  },
  "build": {
    "command": "pnpm build",
    "outputs": ["dist/index.js"]
  },
  "builtArtifacts": ["dist/index.js"]
}

Local builds are intentionally explicit:

  • The user approves the build before OpenWaggle runs it.
  • The command runs in the extension package directory.
  • OpenWaggle stores the build status and a capped build log.
  • Build outputs must also be listed in builtArtifacts.
  • A failed build blocks the extension from being trusted/enabled for runtime use.
  • Changing source files changes the build-plan hash, so the build must be approved again.

Build time is whatever the extension’s build command takes. Runtime rendering does not run that build again.

Trust and capabilities

OpenWaggle extensions are trusted local software after explicit user approval. Trust is not implied by package discovery.

Before trust, OpenWaggle reads the manifest, validates declared files, checks SDK compatibility, calculates the content hash, checks runtime requirements, and shows diagnostics. After trust, runtime loading is still gated by enablement, update state, build status, reload status, project opt-outs, and contribution-level eligibility.

The trust review should make these privileges visible:

  • Trusted visual modules: federated-module entries can run inside OpenWaggle-owned contribution containers after trust, enablement, and reload.
  • Trusted renderer declarations: trusted.renderer is reviewed as privileged metadata, and trusted-renderer entries are still mounted through the isolated extension frame boundary.
  • Trusted local main code: manifests may declare trusted local main code when the extension needs host-side behavior. This is privileged local code and should require explicit trust.
  • Network access: network.origins declares external origins such as https://api.github.com. Undeclared network origins should not be treated as approved.
  • Build scripts: install.source: "local-build" plus build.command asks the user to run local code during build. Build approval is separate from runtime trust.
  • External runtime requirements: runtimeRequirements declares required binaries or commands. Missing requirements block trust or runtime eligibility until fixed.
  • Brokered capabilities: capabilities declares SDK capabilities, methods, and scopes such as openwaggle.storage with get, set, and list for project scope.
  • Session resources: openwaggle.resources grants list-resources and/or publish-resource for session scope. It accepts only credential-free HTTPS links and images, and never exposes managed bytes or private locators. See Session Summary sections and resources.

Trust pins the current package content hash. Editing manifest files, source files, built artifacts, or the build plan changes the hash and creates an explicit update path. Extension updates are user-approved; they are not silent runtime swaps.

Current v1 enforcement is capability-specific:

  • Frame-mounted visual contributions run with iframe sandboxing and a CSP that restricts network connect-src to declared network.origins.
  • Trusted main-process code receives only the public broker SDK for OpenWaggle integration. Direct fetch, node:http, node:https, electron.net, raw node:net, raw node:tls, UDP sockets, direct DNS resolution, node:http2, child processes, cluster forks, and worker threads are guarded while the trusted main module activates and cleans up. Only exact HTTPS origins declared in network.origins are allowed; redirects are not followed through the guard, unresolved targets fail closed, custom fetch agents/dispatchers, custom Node HTTP agents/connection factories/DNS lookup functions, Unix socket paths, raw sockets, and process/isolate escape hatches are rejected.
  • Trusted renderer code is frame-mounted by the current host runtime. Direct app-renderer imports are not supported; extension code must use the brokered SDK/context boundary.

Agent-created and agent-updated packages

Agents may help author project-local or global extension packages, but package writes are not an extension SDK capability. Extension code cannot directly modify another extension package through OpenWaggle. The supported path is an OpenWaggle-owned workflow:

  1. The agent proposes the package id, scope, operation (create, update, or remove), manifest, declared permissions, build plan, and full file list.
  2. OpenWaggle calculates a proposal hash from the operation, extension id, scope, file paths, and file contents.
  3. The user reviews the proposal and approves that exact hash.
  4. For global packages, the user also confirms the global impact because the package can affect every project where it is enabled.
  5. OpenWaggle writes, replaces, or removes the package, then refreshes discovery and lifecycle state.

For create and update package writes, OpenWaggle first returns a proposal view with the operation, normalized file paths, per-file content hashes, byte counts, the full proposal hash, and whether global-impact confirmation is required. Create proposals are valid only when the package does not already exist; update proposals are valid only when the package already exists. If the package state changes between proposal and apply, OpenWaggle rejects the stale operation before writing files. Global write approval must include a second confirmation tied to the same proposal hash and the global-extension-package-write risk marker.

Project-local extension source can be committed and shared with the project. Trust records, enablement, permission grants, build approvals, project opt-outs, lifecycle pins, and extension storage remain user-local.

An approved update replaces the package directory as a full package. Stale files that are not in the new package proposal are removed. Runtime loading is disabled until the updated package is reviewed, trusted or update-approved, enabled, and reloaded again.

An approved remove tears down the runtime path before returning the new Extension Manager view. Registered contributions disappear from the contribution registry, sandboxed module access is denied, and Pi runtime package selection no longer includes the removed package.

State and actions

Extensions must not import writable OpenWaggle stores, renderer feature internals, Pi SDK internals, or Electron app internals. They use the public SDK and brokered capabilities.

The state model is:

  • OpenWaggle state is read-only through typed capabilities such as openWaggle.state.get(scope).
  • OpenWaggle mutations use typed action capabilities such as openWaggle.actions.selectProject(scope, projectPath).
  • Settings access uses typed settings capabilities such as openWaggle.settings.get(scope) and openWaggle.settings.update(scope, settings). Model preferences report Pi’s global default thinking level as thinkingLevel, but an update cannot set it: the thinking level is a Session setting that only the user changes.
  • Session resource access uses bounded openWaggle.resources.list(scope, { limit, cursor }) pages and openWaggle.resources.publish(scope, resource) for the mounted Session only.
  • Extension package state is extension-owned and can be shared by every contribution from the same package.
  • storage.packageState.global and storage.packageState.project are for persistent package state.
  • storage.packageConfig.global and storage.packageConfig.project are for persistent package configuration.
  • Contribution instance state stays local to one mounted contribution and should not be required to reconstruct historical transcript rendering.
  • Pi session data remains the durable source of truth for historical agent-loop records.

Use package state when settings, side panels, transcript renderers, tool renderers, interaction renderers, and status widgets from the same extension need to coordinate. Use instance state for temporary UI state such as focused tabs, expanded rows, or an in-progress form in one mounted surface.

Safe startup and failure isolation

Extension failures must not prevent OpenWaggle from starting.

Expected failure behavior:

  • Invalid manifests, missing files, incompatible SDK ranges, missing runtime requirements, failed builds, and stale trust pins become diagnostics and keep the affected package or contribution out of the contribution registry.
  • A failed contribution registration does not remove unrelated contributions from other extensions.
  • A failed mount is isolated to that contribution container where practical.
  • Standard Pi interaction primitives keep OpenWaggle-owned fallback UI so tools do not hang when a custom renderer is unavailable.
  • A custom Pi interaction without a matching desktop renderer shows an explicit unsupported-interaction fallback with a reject action instead of silently waiting forever.
  • Disable, untrust, project-disable, approve update, approve build, and reload controls remain OpenWaggle-owned recovery paths.
  • Extensions should mount a lightweight shell quickly, then perform slower work such as storage reads or network requests after initial render.

What can make loading feel slow

A prebuilt module that renders from cached state avoids a local build and a network request during mounting.

Loading can take longer when:

  • the extension has not been trusted, enabled, or reloaded yet
  • a local build is required
  • the extension changed and needs update approval
  • many extension roots or project scopes must be discovered
  • the module is large or imports a large UI framework
  • the extension performs async work inside mount(context)
  • the extension fetches network data
  • dev mode is running Vite/Electron rebuilds or stale main-process CSP state

The extension author controls the experience after mount(context) starts. If the extension has slow work, it should render a useful initial state first, then update when the async work finishes.

Pi runtime parity

Runtime behavior stays Pi-native.

Use Pi extension APIs for tools, runtime resources, session hooks, and user interaction. OpenWaggle provides desktop renderers for those Pi events instead of creating a separate OpenWaggle tool runtime.

Common Pi APIs used by extensions include:

  • pi.registerTool() for LLM-callable tools
  • renderCall and renderResult for Pi TUI tool rendering
  • pi.registerMessageRenderer() for Pi TUI custom messages
  • ctx.ui.confirm(), ctx.ui.select(), ctx.ui.input(), ctx.ui.editor(), ctx.ui.notify(), and ctx.ui.custom() for user interaction

OpenWaggle desktop renderers bind to Pi-native identifiers such as tool names, custom message types, standard interaction kinds, and custom interaction types. This keeps Pi TUI and OpenWaggle desktop rendering aligned to the same runtime event.

Agent-loop contributions

Agent-loop contributions render or collect feedback during an active Pi agent loop.

They can be display-only, such as a tool progress card, or interactive, such as an approval dialog. Interactive contributions return typed responses to the pending Pi interaction through OpenWaggle’s brokered extension path.

A contribution should bind to a Pi-native identity:

{
  "contributions": {
    "toolRenderers": [
      {
        "id": "github.issue-tool-card",
        "title": "GitHub Issues Tool Card",
        "runtime": "federated-module",
        "execution": "host-renderer",
        "entry": "dist/github-issue-tool-card.js",
        "matches": {
          "toolNames": ["openwaggle.github.listIssues"]
        },
        "capability": "openwaggle.storage",
        "methods": ["get", "list"]
      }
    ]
  }
}

A single Pi tool or custom message can have multiple OpenWaggle renderers across different surfaces. The transcript is the durable audit trail; dialogs, side panels, status widgets, and composer actions are auxiliary live surfaces.

flowchart LR
  A["Pi agent loop"] --> B["Pi tool call, custom message, or interaction request"]
  B --> C["OpenWaggle projects a public event DTO"]
  C --> D{"Matching extension renderer?"}
  D -- "Yes" --> E["OpenWaggle mounts federated module with context"]
  D -- "No" --> F["OpenWaggle fallback renderer"]
  E --> G["User sees progress, result, or prompt"]
  F --> G
  G --> H{"Does Pi need feedback?"}
  H -- "No" --> I["Transcript keeps durable record"]
  H -- "Yes" --> J["Renderer calls sdk.surface.respondInteraction(response)"]
  J --> K["OpenWaggle broker validates and returns response to Pi"]
  K --> A

The feedback path is intentionally one-way through OpenWaggle-owned surface actions. Extension UI collects the user’s response through context.sdk.surface.respondInteraction(response), then OpenWaggle validates that response against the pending interaction before returning it to Pi. Renderer modules do not mutate Pi sessions or OpenWaggle internal stores directly.

Interaction primitives

OpenWaggle supports Pi interaction primitives as public typed request/response schemas.

Standard primitives have OpenWaggle-owned fallback UI:

  • confirm: prominent confirmation UI plus transcript record
  • select: choice UI plus transcript record
  • input: short text input UI plus transcript record
  • editor: multiline editor UI plus transcript record
  • notify: notification/status UI, with transcript record when relevant

custom is the escape hatch for interactions that do not fit the standard primitives. OpenWaggle does not execute Pi TUI components inside Electron. A custom interaction needs a matching desktop renderer, or OpenWaggle shows an unsupported-interaction fallback with a reject action instead of silently hanging the tool.

Interaction renderer matching uses:

  • standard primitives: matches.interactionKinds values such as confirm, select, input, editor, and notify
  • custom interactions: matches.interactionKinds set to the interaction customType

Renderer modules return responses by calling sdk.surface.respondInteraction(response). For standard primitives, response must match the public interaction response schema, for example { "kind": "confirm", "accepted": true }. For custom interactions, the response value is passed back as the custom interaction result.

Public data boundary

Extension renderer modules receive OpenWaggle public DTOs, not Pi package types or OpenWaggle renderer internals.

DTOs preserve Pi semantics such as:

  • tool name
  • custom message type
  • tool call id
  • interaction id
  • input parameters
  • partial and final result state
  • structured details
  • error state
  • session and project identity

Do not import OpenWaggle stores, renderer feature internals, Pi SDK internals, or Electron app internals from visual contribution modules. Use the public extension SDK surface and brokered capabilities.

State

Pi session data is the durable source of truth for historical agent-loop rendering.

Extension package state can coordinate live surfaces from the same package. Contribution instance state can hold UI-local details for one mounted contribution. Persistent extension data must use typed storage capabilities.

Historical transcript entries must be reconstructable from the mount context and Pi session data after remount, route change, or app restart.

What requires an OpenWaggle app update

Extension authors can ship independently when they stay inside the existing public contract:

  • Add or change manifest-declared contributions in existing families.
  • Add or update federated-module renderer entries that still export mount(context).
  • Add assets, CSS, package-local docs, and built artifacts declared in the manifest.
  • Add or change Pi-native tools, custom messages, resource roots, and runtime behavior supported by Pi and declared in the extension package.
  • Add or change usage of existing brokered SDK capabilities, methods, and scopes already supported by the installed OpenWaggle SDK.
  • Add or change package-owned storage keys and package-owned configuration.
  • Add or change extension-owned UI code that uses existing optional SDK/theme/UI helper modules and still mounts through the same mount(context) contract.
  • Add or remove declared network origins, local build commands, or runtime requirements, subject to user approval.

An OpenWaggle app update is required when the extension needs a new host contract:

  • A new contribution family or a new host-owned surface/container.
  • A new visual runtime besides federated-module.
  • A new execution placement besides the supported placements.
  • A new SDK capability, method, scope, DTO shape, action, or settings schema.
  • A new shared SDK/theme/UI helper export that must be supplied by OpenWaggle instead of being bundled with the extension.
  • A new fallback renderer for an OpenWaggle-owned standard interaction primitive.
  • A new Pi interaction primitive that OpenWaggle must understand as a first-class desktop interaction.
  • A change to extension trust semantics, package discovery roots, content-hash calculation, network approval, CSP/protocol behavior, or build approval rules.
  • A packaged-app distribution change, such as shipping a first-party extension as production content or changing how installed OpenWaggle and Pi docs are generated.

If the installed app reports sdk.openwaggle as incompatible, the extension cannot fix that by changing runtime code alone. Either the extension must target the installed SDK range or the user must update OpenWaggle.

Development fixture

The development-only GitHub Issues Overview fixture exercises the extension lifecycle and SDK.

It should demonstrate:

  • settings and side-panel contributions sharing package state
  • a Pi-native tool such as openwaggle.github.listIssues
  • standard Pi interactions such as confirm
  • a custom desktop interaction or transcript tool renderer
  • fallback behavior when the custom renderer is disabled or unavailable

Development fixtures live under fixtures/extensions/. They are for tests, demos, and local QA only, and must not be shipped as product content.

Use pnpm extension:qa:install to copy fixture packages into the current checkout’s project-local .openwaggle/extensions/ directory for QA. That command is a development helper, not a production packaging step.

Extension host QA proof

Run extension-host service and Settings component coverage from an OpenWaggle checkout:

pnpm test:unit && pnpm test:integration && pnpm test:component

These suites cover discovery, trust, enablement, reload, brokered storage, teardown, and Settings controls. Component tests do not prove that a packaged Electron iframe loads and renders; verify that separately in real-Electron QA.

Human-driven real-Electron QA uses the explicitly visible debug path. Agents must not run this command without the maintainer’s approval for that exact run:

  1. Run pnpm extension:qa:install openwaggle-github-issues-overview.
  2. Start visible Electron with CDP using pnpm dev:debug:headed.
  3. Verify CDP with curl -s http://127.0.0.1:9222/json/version.
  4. Open Settings > Extensions and click Refresh.
  5. Trust GitHub Issues Overview, enable it, then click Reload.
  6. Confirm GitHub Issues Settings appears under Extension settings and the frame titled Extension module: GitHub Issues Settings renders the fixture form.
  7. Save the configuration from inside the frame to prove the brokered storage SDK path.
  8. Disable the extension and confirm the settings contribution disappears.
  9. Remove the extension with the Settings > Extensions Remove action and confirm the package card, contribution registry entry, and sandbox frame disappear. Extension-owned storage is retained unless a separate data deletion flow is explicitly offered.
  10. Check console errors through the Electron QA DevTools path before signing off.

Agent-discoverable installed docs

This page is the repository source of truth for OpenWaggle extension authoring. Packaged builds should derive Pi-style package-local docs from the full user-facing documentation set so self-modifying agents can inspect installed OpenWaggle product, extension, and runtime contracts without relying on a source checkout.

Do not maintain separate hand-written copies for agents. The repository mechanism is pnpm docs:generate, which generates build/openwaggle-docs from website/src/content/docs/** plus installed Pi package docs. If installed package-local docs diverge, fix the build copy step or the source documentation.

Generated installed docs must be easy to navigate:

  • A root README.md explains what the docs bundle contains and where it was generated from.
  • A topic index maps common questions to stable paths, such as extensions, tools, interactions, sessions, settings, providers, MCP, Pi runtime, and QA fixtures.
  • OpenWaggle docs and Pi docs are grouped predictably, so agents do not need to inspect the package layout by trial and error.
  • Important extension authoring entries should have obvious aliases or index links for manifest schema, SDK surface, agent-loop contributions, interaction schemas, and federated module mounting.

Agents should resolve installed docs through a typed docs discovery capability instead of hardcoding source or packaged paths. The same topic map is available to extension code through context.sdk.openWaggle.docs.discover(scope, input) and context.sdk.openWaggle.docs.resolveTopic(scope, input), provided the manifest declares openwaggle.docs with the requested method and scope. OpenWaggle’s self-modifying agent context should use the same broker-backed topic map. The generated index is the manual fallback for tools that only have filesystem access.

Docs discovery should return lightweight metadata rather than file content by default:

{
  topic: 'openwaggle:extending/openwaggle-extensions',
  source: 'openwaggle',
  group: 'OpenWaggle Docs',
  title: 'Agent-loop contributions',
  path: '/path/to/openwaggle/docs/extending/openwaggle-extensions.md',
  bundlePath: 'topics/openwaggle/extending/openwaggle-extensions.md',
  sourcePath: 'website/src/content/docs/extending/openwaggle-extensions.md',
  aliases: ['tool renderers', 'transcript cards'],
  keywords: ['ctx.ui.confirm', 'Pi tools', 'custom interaction'],
  contentHash: 'sha256...'
}

First-party topics should be closed and typed so generated indexes and SDK calls can be validated. Extension packages can also ship Pi-style package-local docs in docs/. Those docs are exposed through a structured extension namespace with provenance metadata and cannot override first-party OpenWaggle or Pi topics. Extension docs are discoverable regardless of trust or enablement; trust and lifecycle state are metadata, not visibility gates.

{
  topic: 'extension:openwaggle-github-issues-overview/configuration',
  localTopic: 'configuration',
  provenance: {
    extensionId: 'openwaggle-github-issues-overview',
    trust: 'trusted',
    lifecycle: 'enabled'
  }
}

Local Pi reference

Agents and developers can inspect the installed Pi docs in a checkout for exact Pi runtime semantics:

  • node_modules/@earendil-works/pi-coding-agent/docs/extensions.md
  • node_modules/@earendil-works/pi-coding-agent/docs/rpc.md
  • node_modules/@earendil-works/pi-coding-agent/docs/sdk.md
  • node_modules/@earendil-works/pi-coding-agent/docs/tui.md

OpenWaggle docs define how those Pi concepts are exposed in the desktop product.