How it works
The rmx API
Every function a remixlet can call. What needs no capability, what each capability unlocks, the limits on each call, and how the contract is versioned.
A remixlet's script runs in its own USER_SCRIPT world with a global named rmx. That object is the whole of what a remixlet can do beyond plain DOM work. This page lists every call on it, what it needs, and where the limits are. The reasoning behind having our own API instead of GM_* is in Why change what works?.
How the bridge works#
The functions on rmx do nothing locally. Each one sends a message to the extension's background worker, which checks that the message carries the secret token belonging to that remixlet's world, that the call came from a page the remixlet's matches cover, that the remixlet is not paused, and that the capability the call needs is in the granted list. Only then does it do the work. A call that fails any check rejects with an Error whose message names the reason, and the denial is written to the remixlet's log so the agent can see it.
Scripts declared with "world": "MAIN" never get rmx. They get one lexical handle, rmxRelay, which can only post data back to the sandboxed side. See Relay from the page world.
A script runs at most once per document, the first time the page URL satisfies matches. On a site that navigates client-side, that first time may be after a pushState, and the script stays loaded if the user then navigates away within the app. React to those route changes with rmx.navigation.onChange.
No capability needed#
These are always present. They are telemetry and page plumbing, not powers, so nothing about them reaches the approval screen.
rmx.keep#
const stop = rmx.keep(label, { when, ensure, apply });The sanctioned way to keep a page condition true on pages that redraw themselves. The remixlet says what should hold and the bridge does the watching, so remixlet code never wires its own watch-and-reapply observer, which is the pattern that freezes tabs.
labelis a short plain-words name for the condition, up to 120 characters. It is how the keep shows up in the log.when()is optional and answers "is the page piece this feature attaches to present?" Returning false idles the keep without penalty.ensure()answers "does the desired state hold right now?"apply()establishes that state, synchronously.
All three callbacks must be synchronous and fast, and only apply may write to the page. The bridge evaluates every keep at registration, after DOM mutations through one shared observer, and on route changes. apply runs only while when passes and ensure is false. If three consecutive applies leave ensure false, or the callbacks throw three times in a row, the keep halts and logs why. Frequent successful reapplies are fine. That is the job on React-style sites that revert foreign DOM edits on every render, and past 25 reapplies in a minute the bridge logs one informational notice and carries on. The returned function unregisters the keep.
rmx.keep("total length shown on each album card", {
when: () => document.querySelector("[data-album-grid]") !== null,
ensure: () => document.querySelectorAll(".rmx-length").length === lengths.size,
apply: () => {
for (const [id, text] of lengths) {
const card = document.querySelector(`[data-album-id="${id}"]`);
if (card && !card.querySelector(".rmx-length")) card.append(badge(text));
}
},
});rmx.navigation.onChange#
const stop = rmx.navigation.onChange(({ url, previousUrl }) => {});Fires when location.href actually changes: popstate, hashchange, and the pushState and replaceState calls the extension's page-world watcher reports over the relay. Every signal is treated as a hint. The hub re-reads the URL itself and only notifies on a real change, so a page forging navigation events can at most trigger a harmless re-check.
Relay from the page world#
// In a "world": "MAIN" file:
rmxRelay.post(topic, data);
// In a USER_SCRIPT file:
const stop = rmx.relay.on(topic, (data) => {});The only channel between a remixlet's page-world files and its sandboxed files. data must survive JSON.stringify. Registering a listener asks the page side to replay what it has buffered, so a late listener still sees earlier posts. Everything arriving through the relay is untrusted page data: the page shares the world it was posted from, and the relay token authenticates the channel without carrying any authority.
rmx.log#
rmx.log.warn(message);
rmx.log.error(message);Writes to the per-remixlet log the agent reads back when something goes wrong. Report anomalies here instead of swallowing them in a catch: a page that parsed zero records, a response with an unexpected shape. Messages are cut at 500 characters and the log keeps the last 50 entries per remixlet.
The remixlet's own console.log, info, warn, and error calls are captured into the same log. The capture is per world, so the host page's console output is never recorded. It stops after 100 lines per page load and says so in the last slot. Runtime exceptions from the script itself land there too.
The MutationObserver budget#
Not a function, but part of the runtime every script gets. MutationObserver in a remixlet world is budgeted at 40 callback runs per second. Past that, deliveries coalesce into one trailing run per second. A well-behaved observer never notices. A callback that writes to the DOM unconditionally, and so retriggers itself forever, degrades to a warning and a throttled steady state instead of freezing the tab and taking every kill switch with it. The bridge tells the two cases apart by how fast the budget burns and logs a feedback-loop warning for the first and an informational throttle notice for the second.
Capability-gated calls#
Each of these needs the named capability in the manifest, with a rationale, and your approval at activation.
rmx.storage#
Needs storage.
const value = await rmx.storage.get(key); // undefined when unset
await rmx.storage.set(key, value); // value must be JSON-compatible
await rmx.storage.delete(key);
const stop = rmx.storage.watch(key, (value) => {});One key-value store per remixlet, shared across every host and tab it runs on, invisible to pages, and untouched when a site clears its own storage. Values must be JSON-compatible: strings, numbers, booleans, null, arrays, and plain objects.
watch is a long-poll against the worker. The callback fires with the current value of the watched key whenever the remixlet's store changes, including changes to other keys, so compare against your last value if that matters. A dying service worker just drops the connection and the bridge re-polls, so watches survive the browser putting the extension to sleep.
rmx.fetch#
Needs fetch:host for the host being requested. fetch:api.example.com covers exactly that host. fetch:*.example.com covers the apex and every subdomain. fetch:example.com does not cover www.example.com, which is the mistake the denial message spells out.
const response = await rmx.fetch(url, { method, headers, body, timeoutMs });
response.status; // number
response.ok; // 200 to 299
response.headers; // a Headers object
response.redirected; // boolean
response.url; // the final URL
response.body; // the text
await response.text();
await response.json();The request runs from the extension, not the page, so it carries your cookies for that host and is not subject to the page's CORS policy. That is why it is host-scoped and why every control on it lives in the extension.
| Rule | Value |
|---|---|
| Schemes | http and https only, no embedded credentials |
| Methods | GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS |
| Request body | strings only, up to 1 MB, not on GET or HEAD |
| Request headers | up to 32 KB; cookie, origin, referer, user-agent, host, and the other browser-owned headers are refused |
| Response | up to 2 MB, otherwise the call fails |
| Timeout | 15 seconds by default, timeoutMs up to 30000 |
| Redirects | up to 5, and every hop must stay inside the granted host patterns |
The returned object is frozen and is not a real Response. The body has already been read as text.
rmx.network.onResponse#
Needs network:observe:host, same host grammar as fetch:.
const stop = rmx.network.onResponse(hostPattern, (entry) => {
entry.url; // the request URL
entry.status; // number
entry.contentType; // string or null
entry.body; // text, up to 512 KB
entry.truncated; // true when the body was cut at that cap
entry.seq; // increasing per page
});The extension installs a pass-through interceptor in the page world for the granted hosts. The page always receives its original response; observation reads a clone. It covers fetch and XMLHttpRequest with text or JSON response types.
hostPattern narrows within the grant: an exact host, a *. pattern, an empty string for everything granted, or any substring of the URL. Responses that arrived before your listener registered are replayed from a ring buffer of 250 entries and 6 MB total, oldest evicted first. On a busy API the load-time responses may already be gone by the time a document_idle script registers, so treat a missing response as something to re-trigger, not something to wait for.
The callback usually fires before the page has rendered. Treat it as a data-arrival signal, store what you parsed, and let a rmx.keep mount it when the target element appears. Bodies are untrusted page data.
rmx.notifications#
Needs notifications.
const id = await rmx.notifications.show(title, message);
const cleared = await rmx.notifications.clear(id);Text only. The title is capped at 120 characters and the message at 1000. There are no icons, buttons, or URLs, and a remixlet can only clear notifications it created.
rmx.clipboard#
Needs clipboard.
await rmx.clipboard.writeText(text);Write only, text only, up to 1 MB of UTF-8. There is no read.
rmx.menu#
Needs menu.
const registrationId = await rmx.menu.register(id, label, callback);Adds a command to Remixlet's toolbar popup for the current site and tab. id is up to 128 characters and label up to 160. Registering the same id again replaces the earlier command. The callback runs in the page when you click the command, and the command disappears with the document it was registered from. The worker stores only the label and pending clicks; callbacks never leave the sandboxed world.
rmx.schedule#
Needs schedule.
await rmx.schedule.at(id, when, action); // when: epoch ms or ISO date string
await rmx.schedule.every(id, minutes, action); // at least 0.5 on Chrome, 1 on Firefox
await rmx.schedule.register({ id, at, action }); // or { id, every, action }
await rmx.schedule.remove(id); // true if something was removed
await rmx.schedule.list(); // { timed, onSiteOpen }
await rmx.schedule.clear();
await rmx.schedule.onSiteOpen(hookName, callback);
await rmx.schedule.removeOnSiteOpen(hookName);
const stop = rmx.schedule.onHook(hookName, callback);Ids and hook names are up to 64 characters of letters, digits, and _ . : -, starting with a letter or digit.
The scheduler stores and executes data only. The service worker never evaluates remixlet JavaScript, so an action is one of three fixed shapes:
| Action | What happens |
|---|---|
{ type: "notify", title, message } | Shows an extension-owned text notification. Same length caps as rmx.notifications. |
{ type: "open", url } | Focuses a tab already at that exact URL or opens a new one. The URL must be absolute, http(s), and inside the remixlet's matches. |
{ type: "hook", name } | Queues the name until a matching page next runs, where rmx.schedule.onHook(name, callback) receives it. |
Timers fire whether or not a matching tab is open. A one-shot at schedule is removed after it fires. If the browser was closed when a timer was due, the action runs once at the next startup and a repeating schedule moves to its next future slot. Pausing the remixlet on any of its sites stops every schedule until you resume.
onSiteOpen registers a hook that fires when you first open one of the remixlet's sites in a browser session. Register it with a callback to also listen in the same call. Site-open hooks are delivered the same way as timed hook actions, through onHook.
Network rules are not a call#
netrules unlocks a netRules entry in the manifest naming a JSON file of declarativeNetRequest rules. Nothing on rmx touches them. The extension validates the file at write time, installs the rules when the remixlet activates, and removes them when it pauses or deactivates. Only block, redirect, upgradeScheme, and modifyHeaders are accepted, rules are pinned to the remixlet's own sites, security headers are off limits, redirects must target http(s), and a file holds at most 1000 rules.
The contract#
rmx.* is a published contract. A remixlet may keep running long after the conversation that wrote it has ended, there is no build step to catch a breaking change, and nobody is expected to patch every stored script. So within a bridge version the API only grows: new methods and new fields are fine, and renaming, removing, or changing the observable behavior of anything an existing remixlet can call is a breaking change.
The extension stamps the bridge version into every remixlet at write time, in the manifest's builtWith field. A breaking change bumps that version. A stored remixlet whose stamp falls outside the range the installed extension supports is parked as needing repair instead of injected to fail on your page. The current bridge version is 1.
What is not there#
- No DOM helpers or bundled libraries. Scripts use the platform's own APIs.
- No read access to the clipboard, and no way to show a notification with a button.
- No "fetch anything" grant. Every host is named and approved.
- No
rmxin the page world.page-worldfiles getrmxRelay.postand nothing else. - No way to load or evaluate code that was not in the folder you approved.