Skip to main content
The examples/aether-extension package in the Aether repository demonstrates how to combine Pi agent-level tools with Aether UI extensions in a single package. This walkthrough covers every part of the example: the package manifest, the Pi factory, the Aether factory, surface and page registration, storage, and the before_send event hook.

What this extension does

  • Registers a Pi /aether-example command that notifies the agent when the extension is active
  • Adds a card surface above the composer with a live counter button and a prompt prefill action
  • Adds a full-screen drawer page (“Extension dashboard”) with a persistent count card and an embedded WebView
  • Bridges the WebView back to extension actions via Aether.postMessage
  • Persists the counter value in extension storage across sessions
  • Hooks into the before_send event to strip a !raw prefix from outgoing messages

Package declaration

The package.json lists the same index.ts entry point under both pi.extensions and aether.extensions, so a single file powers both the Pi agent side and the Aether UI side:

Full annotated code walkthrough

1. Imports and defineAetherExtension

  • ExtensionAPI types the pi argument in the Pi factory (default export).
  • defineAetherExtension wraps the Aether factory and provides type-safe access to the aether context object.
  • ui is the declarative UI builder used in every render callback.

2. The Pi factory (default export): registering /aether-example

Aether loads this as a standard Pi extension because the file is declared under pi.extensions. You export the Pi factory as the default export. The handler does nothing more than fire an info notification — it is a minimal proof that the Pi and Aether sides of the package are live simultaneously.

3. The Aether factory (activateAether): counter action + prefill action

You export the Aether factory as activateAether (a named export). Aether discovers it because the file is also listed under aether.extensions.
  • increment reads the current count from aether.storage (defaulting to 0), increments it, writes it back, then shows an Android toast via aether.host.invoke("app.notify", ...). Because storage is synchronous and persisted in ~/.aether/app-extension-state.json, the value survives hot reloads and app restarts.
  • prefill uses app.setDraftInput to replace the composer text with a ready-made prompt. You can trigger this from any button in the UI by passing the action ID "prefill".

4. Surface registration at chat.composer.top

registerSurface attaches content to the chat.composer.top slot — directly above the text input. Key points:
  • order: 10 — controls stacking order when multiple extensions register the same slot.
  • render context — receives live app state. storage holds your extension’s persisted key/value pairs; is_running reflects whether the agent is currently generating.
  • ui.card — wraps all content in a rounded card. radius: 22 rounds the corners more aggressively to match the composer shape.
  • ui.row with arrangement: "space-between" — stretches the label and status text to opposite ends.
  • ui.row with wrap: true — the three buttons wrap onto a second line on narrow screens; rowSpacing: 8 adds vertical breathing room between lines.
  • ui.node("pageButton", { page: "dashboard", ... }) — a special button type that navigates to the registered page with ID "dashboard". It opens the page in the conversation drawer.

5. Page registration with WebView and action bridge

registerPage creates a full-screen destination that Aether automatically lists in the conversation drawer. Points to note:
  • id: "dashboard" — this string must match the page field on the pageButton node registered in the surface above.
  • render context — receives the same app state object as surfaces. storage.count reads the persisted counter.
  • ui.web — injects an arbitrary HTML document into a native Android WebView. JavaScript, DOM storage, and network access are all enabled inside the WebView.
  • Aether.postMessage(JSON.stringify({ action, args })) — the only bridge from WebView JavaScript back to extension logic. When the user taps “Increment through JavaScript”, the WebView posts a message that Aether routes to the registered increment action. You can pass arbitrary args in the JSON object; the action handler receives them as its first argument.
  • ui.column with scroll: true — makes the page body vertically scrollable when content overflows the screen.

6. The before_send event hook

aether.on("before_send", handler) fires immediately before the user’s message is submitted to the model. The handler receives the draft payload and may return a partial object to merge back in. Here, any message beginning with !raw has that prefix stripped before dispatch. Returning undefined (or nothing) leaves the payload unchanged.

Installation

  1. Zip the examples/aether-extension directory into a .zip archive.
  2. Open Aether on your Android device.
  3. Go to Settings → Extensions → Import.
  4. Select the zip file. Aether extracts it to ~/.aether/extensions and hot-loads the Script side immediately.
Read through this example alongside the Script Mod API docs for surfaces, pages, storage, and events to understand how the pieces fit together. The combined extension is intentionally minimal — every capability it uses is independently composable, so you can copy individual sections (e.g., just the before_send hook, or just the surface) into a new package without any of the other parts.