Writing an extension

This guide builds a small extension, a reading list, one piece at a time: a widget, a command, a tab with a form, and records that MCP clients can add to. The full method and scope lists are in the API reference; the manifest rules and how to get into the community index are in Sharing extensions.

What you need

1. Make the folder and manifest

Create <workspace folder>/.aquamarine/extensions/reading-list/manifest.json:

{
  "id": "reading-list",
  "name": "Reading list",
  "version": "0.1.0",
  "minAppVersion": "0.1.0",
  "description": "Books to read, as a tab and a widget.",
  "author": "You",
  "scopes": [],
  "main": "main.js"
}

The folder name must match id. Leave scopes empty until the code calls something that needs one; every scope is shown to the person when they enable the extension.

2. Add a widget

Create main.js next to the manifest:

function escape(text) {
  return String(text ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
}

aquamarine.widgets.register({
  kind: "summary",
  name: "Reading list",
  render() {
    return "<p>Nothing to read yet.</p>";
  }
});

render returns an HTML string. It may be async. The app cleans the HTML before showing it: no images, styles, scripts, forms or anything else that loads from the network. Escape every value you put in it anyway, so a title with a < in it shows as text.

3. Load it

  1. Open Settings → Extensions and press Rescan folder.
  2. Reading list appears with "From the extensions folder". Enable it.
  3. Open the widget tray (▣ in the tab strip) and add "Reading list" to a pane.

After every change to main.js or the manifest, press Rescan folder again. If the code throws while loading, or a registration is invalid, the error shows under the extension's name in Settings → Extensions. A folder whose manifest is invalid is listed as "Folder skipped" with the reason.

4. Add a command

Commands show in the palette (Ctrl+K) as "Reading list: ", and a widget can run one from a button:

aquamarine.commands.register({
  id: "open",
  name: "Open reading list",
  async run() {
    await aquamarine.workspace.open("ext/reading-list/books");
  }
});

In the widget, <button data-command="open">Open</button> runs it. workspace.open only works while a command or a view action runs, so an extension cannot move the person around on its own.

5. Store records

Declare a collection in the manifest. The server checks every record against its JSON Schema and stores it encrypted:

"collections": {
  "books": {
    "description": "A book on the reading list.",
    "schema": {
      "type": "object",
      "additionalProperties": false,
      "required": ["title"],
      "properties": {
        "title": { "type": "string", "minLength": 1, "maxLength": 300 },
        "author": { "type": "string", "maxLength": 200 },
        "read": { "type": "boolean" }
      }
    }
  }
}

Bump version (to 0.2.0) whenever collections or tools change. The app registers them with the server only when it sees a new version, so without the bump the server keeps the old schema.

The extension reads and writes its own records without any scope:

const books = await aquamarine.records.list("books");
const book = await aquamarine.records.create("books", { title: "Piranesi", author: "Susanna Clarke" });
await aquamarine.records.update("books", book.id, { read: true });

A record is { id, collection, data, created_at, updated_at, ... }; your fields are under data. update merges, and a field set to null is removed. A collection holds up to 5000 records of up to 256 KB each.

6. Add a tab

A view is a sidebar tab with its own routes. The route ext/reading-list/books/<path> calls render({ path }), and the view renders again after each action and whenever the extension's records change.

aquamarine.views.register({
  id: "books",
  name: "Reading list",
  icon: "book",
  async render({ path }) {
    if (path === "new") {
      return `<header><h1>Add a book</h1></header>` +
        `<form class="ext-form" data-action="add">` +
          `<label class="ext-field"><span>Title</span><input name="title" required></label>` +
          `<label class="ext-field"><span>Author</span><input name="author"></label>` +
          `<div class="form-actions"><button class="btn-primary">Add</button>` +
          `<button type="button" class="btn" data-go="">Cancel</button></div>` +
        `</form>`;
    }
    const books = await aquamarine.records.list("books");
    const rows = books.map((b) =>
      `<li>${escape(b.data.title)}${b.data.author ? ` <span class="muted">${escape(b.data.author)}</span>` : ""} ` +
      `<button class="btn small" data-action="toggle" data-value="${escape(b.id)}">${b.data.read ? "Mark unread" : "Mark read"}</button></li>`
    ).join("");
    return `<header><h1>Reading list</h1><div class="actions"><button class="btn small" data-go="new">Add a book</button></div></header>` +
      (rows ? `<ul class="ext-rows">${rows}</ul>` : `<p class="empty">No books yet.</p>`);
  },
  async action(name, data, { path }) {
    if (name === "add") {
      await aquamarine.records.create("books", { title: data.title, ...(data.author ? { author: data.author } : {}) });
      return { go: "" };
    }
    if (name === "toggle") {
      const book = await aquamarine.records.get("books", data.value);
      await aquamarine.records.update("books", book.id, { read: !book.data.read });
    }
  }
});

How the HTML talks back to the extension:

Markup What happens
data-go="<path>" Opens that path in the view. "" is the view's root.
<form data-action="x"> On submit, action("x", fields, { path }). A field named x[] arrives as a list.
<button data-action="x" data-value="v"> On click, action("x", { value: "v" }, { path }).
<input>/<select> with data-action On change, action("x", { value }, { path }); value is the field's value, or true/false for a checkbox. A checkbox's data-value arrives as item.
data-url="https://..." Opens the address in the system browser. href is removed.
data-command="<id>" Runs one of the extension's commands.
data-note="<page id>" Opens that page of the workspace.
data-key="n" Clicked when the person presses that key outside a text field. Escape works too.

action may return { go: path } to move to another path, and toast for a line in the status bar: { toast: "Saved" }, or { toast: { text: "Deleted", undo: { action: "restore", value: id } } } for one with an Undo button that runs that action. Paths are segments of letters, digits, - and _ separated by /, such as <record id>/edit.

The same tab, with search, a form built from the schema, editing and Delete with Undo, takes one call:

aquamarine.ui.crud({ id: "books", name: "Reading list", icon: "book", collection: "books", singular: "book" });

Components covers it and the other builders: forms, tables, confirmations, dates, charts, page and tag pickers and sortable lists.

Do not name a form field name, id or anything else that is a property of a form element: the app removes it. The recipes extension uses recipe_name for this reason.

icon is one of chef-hat, users, book, heart, star or box. The app's ext-* classes (ext-form, ext-field, ext-rows, ext-chips, ext-toggle, ext-inline, ext-search, ext-foot and others) and its buttons (btn, btn-primary, btn-danger, small) follow the person's theme. There is no way to add your own CSS.

7. Let MCP clients use the records

Each entry in the manifest's tools becomes an MCP tool named <id>_<name>, with dashes in the id turned into underscores:

"tools": [
  { "name": "add", "collection": "books", "action": "create", "description": "Add a book to the reading list. Put only the title in title and the author's full name in author." },
  { "name": "list", "collection": "books", "action": "list", "description": "List the reading list, newest first." }
]

This adds reading_list_add and reading_list_list. The server answers them itself; no extension code runs there. The description is all the MCP client sees, so say what format each field should have. Bump version and rescan.

8. Settings and events

aquamarine.settings.get() and settings.set(values) keep up to 64 KB of JSON for the extension on this device only. Use records for anything that should sync.

aquamarine.events.on("change", fn) runs fn when synced data changes. events.on("view", fn) runs when the active tab changes and needs read:workspace. Both return a function that stops listening. Views and widgets already re-render on these, so most extensions need neither.

What the sandbox allows

The code runs in a Web Worker inside a sandboxed frame. Its only binding to the app is aquamarine.

Rule Limit
Network, storage, other workers None: fetch, XMLHttpRequest, WebSocket, indexedDB, caches, importScripts and the like are removed
DOM None; return HTML strings
main.js size 512 KB
Start-up Must finish loading within 10 s
API call 5 s
Widget render 5 s, output cut at 200 KB
View render 20 s, output cut at 200 KB
Command or view action 2 min
Registrations 10 widgets, 5 views, 20 commands per extension
Ids (kind, view and command id) Lowercase letters, digits and dashes, up to 40 characters

A method outside the extension's scopes rejects with an error whose scope property names the missing scope. Adding a scope in a new version stops the extension until the person disables and enables it again, so they see the new request.

Debugging

Examples

When it works, follow Sharing extensions to add it to the index.