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
- The desktop app with a workspace folder chosen (Settings → Workspace →
Folder). Extensions under development load from
<workspace folder>/.aquamarine/extensions/<id>/, and only the desktop app reads that folder. - A text editor. There is no build step: the app runs
main.jsas it is, so write plain JavaScript or bundle into one file yourself.
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
- Open Settings → Extensions and press Rescan folder.
- Reading list appears with "From the extensions folder". Enable it.
- 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
- Errors from loading, registering, or an event handler show in Settings → Extensions under the extension's name.
- An error thrown from
renderoractionshows in place of the widget or view. - There is no console. To inspect a value, render it:
<pre class="ext-pre">${escape(JSON.stringify(value, null, 2))}</pre>. - "did not answer in time" means a render, call or action ran past its limit above.
Examples
- word-count: a widget and a command, about 40 lines.
- recipes: a view with list, detail, edit and delete paths, forms, toggles and MCP tools.
- people: a second records-backed view.
When it works, follow Sharing extensions to add it to the index.