Page extensions
A page extension adds a full page to the app, served at /x/<id>. It is a plain JavaScript
module – no build step – plus an optional Python API.
Where it appears: every page is listed under Extensions in the sidebar (the /x page). Click the
pin at the top right of a page (or on the Extensions page, or turn on In the sidebar in
Settings -> Extensions) to give it its own sidebar entry, right below Extensions; click it again to unpin.
/root/maxxing/extensions/notes-board/
manifest.json
page.js the page (ES module)
api.py optional: its HTTP API
Ask Claude "create a page that ..." and the maxxing-extension skill writes it for you, or follow
these steps.
A new install comes with one page, Your pages (extensions/pages/): what a page extension is, and
examples (client file, order tracker, invoices...) that open a session with the request already typed. It is
an ordinary extension: delete its folder, or turn it off in Settings -> Extensions, when you no longer need it
- updates do not bring it back.
1. manifest.json
{"id": "notes-board", "name": "Notes board", "type": "page", "icon": "sticky-note", "version": "1.0.0"}
name can also be {"en": "Notes board", "fr": "Tableau de notes"}: the app shows the one of its language.
"api": 1 (optional, default 1) is the version of the extension contract the page is written for: if a future
release stops supporting it, the update preflight says so before you update (Settings -> System).
2. api.py (optional)
Every request to /api/ext/<id>/<path> calls:
def handle(ctx, method, path, query, body):
...
return {...} # sent as JSON (200)
# or: return 404, {"error": "not_found", "message": "..."} to choose the status code
| Argument | Value |
|---|---|
ctx |
same as for widgets: id, workspace, state_dir, secret(name), config |
method |
GET, POST, PUT, PATCH, DELETE |
path |
the part after /api/ext/<id>/ (e.g. notes, notes/42) |
query |
query-string parameters as a dict |
body |
parsed JSON (or text), None for GET; max 5 MB |
It runs in a worker thread with a 30-second limit. Requests are authenticated by the app: only a signed-in user can call it.
Example:
import json
import time
from pathlib import Path
def handle(ctx, method, path, query, body):
f = Path(ctx["state_dir"]) / "notes.json"
notes = json.loads(f.read_text()) if f.exists() else []
if path == "notes" and method == "GET":
return {"notes": notes}
if path == "notes" and method == "POST":
text = str((body or {}).get("text", "")).strip()[:500]
if text:
notes.insert(0, {"text": text, "at": time.time()})
tmp = f.with_suffix(".tmp")
tmp.write_text(json.dumps(notes[:200]))
tmp.replace(f)
return {"notes": notes}
return 404, {"error": "not_found", "message": f"{method} {path}"}
3. page.js
The app loads /ext/<id>/page.js and calls mount(el, ctx):
ctx |
|
|---|---|
api(path, opts?) |
calls /api/ext/<id>/<path> with authentication and CSRF header; opts = {method, body} (body is sent as JSON); resolves to the parsed JSON, rejects on HTTP errors |
navigate(route) |
go to another page of the app, e.g. navigate("/tasks"); /sessions?path=<topic path>&new=1&prompt=<text> opens a new session with the text typed in |
theme |
"dark" or "light" |
lang |
the app's language, "en" or "fr" |
t(key) |
translation helper of the app |
icon(name) |
an icon of the app as an <svg> element (same names as the manifest icon), e.g. button.prepend(ctx.icon("plus")) |
Return a cleanup function: it runs when the user leaves the page.
For security the app only runs scripts it serves itself: page.js cannot add inline <script> tags to the page
nor build code from text (eval); split the code into modules of the extension folder instead. HTML widgets keep
working as before.
The module is downloaded once per version of page.js and kept: code at the top of the file runs once,
mount runs at every visit. Saving page.js gives it a new version, picked up at the next visit.
export function mount(el, ctx) {
el.innerHTML = `
<div class="ui-page" style="max-width:640px">
<h1 class="ui-h1">Notes board</h1>
<form class="ui-row">
<input class="ui-input" name="text" placeholder="New note" style="flex:1">
<button class="ui-btn">Add</button>
</form>
<ul class="ui-list"></ul>
<p class="ui-muted" hidden>No note yet: write the first one above.</p>
</div>`;
const list = el.querySelector("ul");
const empty = el.querySelector("p");
const form = el.querySelector("form");
form.querySelector("button").prepend(ctx.icon("plus"));
const render = ({ notes }) => {
list.hidden = !notes.length;
empty.hidden = !!notes.length;
list.replaceChildren(...notes.map((n) => {
const li = document.createElement("li");
li.textContent = n.text; // never inject data with innerHTML
return li;
}));
};
ctx.api("notes").then(render);
const onSubmit = async (e) => {
e.preventDefault();
const text = form.text.value.trim();
if (!text) return;
render(await ctx.api("notes", { method: "POST", body: { text } }));
form.reset();
};
form.addEventListener("submit", onSubmit);
return () => form.removeEventListener("submit", onSubmit);
}
Styling
The page gets the app's UI kit: CSS classes that look exactly like the rest of the app and follow the theme, the accent color and the density by themselves. Prefer them to hand-written styles:
| Classes | |
|---|---|
ui-page ui-section ui-grid ui-row ui-stack ui-spacer |
layout: page column, section, responsive grid of cards, row, column |
ui-h1 ui-h2 ui-h3 ui-text ui-muted ui-small ui-label ui-num |
text (title, section title, body, secondary, labels, figures in columns) |
ui-card ui-card-header ui-card-footer |
a card; the header takes an ui-icon-box and a <div> with the title and a line |
ui-tile |
a clickable card: <button class="ui-tile"><div class="ui-icon-box">svg</div><div><b>Title</b><span>line</span></div></button> |
ui-icon-box (+ ui-lg, ui-neutral) |
an icon on a soft square of the accent color |
ui-btn (+ ui-secondary ui-outline ui-ghost ui-danger, ui-sm, ui-icon-only) |
buttons (ui-btn alone = main action) |
ui-field ui-label ui-input ui-select ui-textarea ui-check |
forms |
ui-badge ui-dot (+ ui-success ui-warning ui-error ui-primary ui-outline) |
statuses |
ui-list ui-table ui-table-wrap ui-stat ui-stat-value ui-stat-label ui-progress |
data |
ui-steps ui-step-num ui-callout (+ ui-info ui-success ui-warning ui-error) ui-empty ui-skeleton |
steps, notices, empty and loading states |
ui-code ui-kbd ui-link ui-separator |
the rest |
Icons come from ctx.icon(name); use them instead of emoji. Your own CSS always wins over the kit, and the app's
CSS variables are there for the rest: --background, --foreground, --card, --border, --muted,
--muted-foreground, --primary, --primary-foreground, --success, --warning, --destructive. HTML widgets
get the same kit. Inputs of the kit are 16 px on phones (no zoom on iPhone).
4. Test and load
- Python side: call
handle()directly (see the test snippet in Dashboard widgets, withapi.handle(ctx, "GET", "notes", {}, None)). - JavaScript syntax:
node --check page.jsif Node is installed. - Settings -> Extensions -> Reload, then open the page from the sidebar (Extensions, or its own entry once pinned). Errors appear on the extension's row.