How it works
The dom API
How a remixlet reaches the page it runs on. Handles instead of live nodes, every call and its limits, and the writes the page agent refuses.
A remixlet's script never holds the page. It runs in a sandbox the extension owns, and the sandbox has no document. What it has is dom, an object whose every call is a message to the page agent, a content script we ship that lives in the page's isolated world and owns the real document. The agent runs each read as asked and checks each write against a fixed policy before it touches anything. This page is the reference for that object: what it gives you, what each call costs, and where it says no. The agent and the sandbox introduces the runtime it sits in. Why the page is out of reach in the first place is in Security architecture.
What the sandbox gives you#
A remixlet file is loaded as a blob script under a content security policy with no network directive and no eval. Before it runs, every page-shaped global is shadowed. document, fetch, XMLHttpRequest, MutationObserver, localStorage, WebSocket, Image, Worker, alert, open and their relatives are replaced by a value that throws on any use, with a message that names what to use instead. Reaching for document tells you about dom.query; reaching for localStorage tells you about rmx.storage.
Three globals survive as read-only stand-ins built from facts the page agent pushes into the sandbox. location has the URL parts of the page; assigning to it, or calling assign, replace or reload, throws. navigator has language, languages, userAgent, platform, hardwareConcurrency and onLine, and nothing that acts. window (also self, globalThis, top, parent, frames) has the timers, requestAnimationFrame shimmed onto a 16 ms timeout because an offscreen document never paints, the JavaScript builtins, innerWidth, innerHeight, scrollX, scrollY, addEventListener routed to dom.window, scrollTo and scrollBy routed to dom, and the two globals. Anything else on it throws.
Timers are the sandbox's own and are not throttled. console.log and its siblings are captured into the remixlet's log, 500 characters per line and 100 lines per page load. The files run once per document, when the page's readyState satisfies the script's runAt and the URL first satisfies the manifest's matches. On a site that navigates client-side that may be after a pushState, and the script stays loaded across later route changes; rmx.navigation.onChange reports them.
Handles#
const price = await dom.query(".price"); // Handle | null
await price.setText("12 min");A handle is an opaque id the page agent minted for one node. It is not the node, and nothing on it is live. Each method is one round trip to the agent unless the description says otherwise, which is why independent calls belong in a Promise.all. A handle for a node the page removed keeps answering reads until the node is gone from memory; if a listener was bound to it when the page redrew it, the handle is stale and every call on it rejects with stale: the node this handle named left the document, except isConnected(), which answers false, and release().
dom.document, dom.body and dom.head are handles that are always valid. dom.window is an events-only handle, described under Events.
The agent holds at most 20000 handles per sandbox. Past that, every call that would mint one fails until you release() some. The sandbox holds at most 5000 unanswered calls at once. A selector longer than 4096 characters is refused before it is sent.
Finding elements#
const one = await dom.query(selector); // Handle | null
const all = await dom.queryAll(selector, { info, limit }); // Handle[]
const late = await dom.waitFor(selector, { timeoutMs }); // Handle | nullquery and queryAll are querySelector and querySelectorAll on the document, or on the handle when called as handle.query(...). queryAll returns at most 500 handles; limit lowers that. With { info: true } the same round trip also fills each handle's snapshot with its element info, so filtering a hundred rows costs one message rather than one per row. An invalid selector rejects with invalid selector.
waitFor resolves with the first match, now or later. It re-checks after page mutations, at most every 50 ms, and resolves null at the deadline. The default window is 10 seconds and the ceiling is 120. Use it for content the page itself loads late. For a condition that has to keep holding, use rmx.keep instead.
Walking from a handle: closest(selector), parent(), children() (at most 500), first(), last(), next(), prev(), all element-only, all Handle | null except children. matches(selector) and contains(other) answer booleans. shadow() answers the element's open shadow root as a handle you can query under, or null when the root is closed or absent. isConnected() says whether the node is still in the document.
Reading#
const info = await handle.info();
// { tag, id, className, text, attrs, dataset, value?, checked?, rect, visible }info() is the one read to reach for first. text is the trimmed textContent cut at 2000 characters, attrs and dataset are every attribute and data- value, value and checked appear on form controls, rect is the bounding box, and visible means laid out with a non-zero size and neither display: none nor visibility: hidden. The result is also stored on handle.snapshot. It is a snapshot, not a view.
| Call | Answers |
|---|---|
text() | textContent, up to 64 KB |
innerText() | the rendered text, what a user sees, up to 64 KB |
html() | innerHTML, up to 64 KB |
attr(name) | the attribute or null |
data(name) | the data- attribute for a dataset name, so data("itemId") reads data-item-id |
value() | a form control's value, or an empty string for anything else |
computed(prop) / computed([props]) | one computed style value, or a record of up to 100 |
rect() | the bounding box |
visible() | info().visible on its own |
Every read is allowed. The policy only ever looks at writes, because a read that cannot leave the sandbox harms nobody.
Building and placing#
const badge = await dom.create("span", {
text: "12 min",
classes: ["rmx-lengths-badge"],
attrs: { title: "Total length" },
style: { "margin-left": "8px" },
});
await price.after(badge);dom.create(tag, options) builds a detached element and answers its handle. The options are text, attrs, classes, style, html (sanitised the same way setHTML is), and children, an array of nested { tag, ...options } specs where a plain string is a text node. A whole control is therefore one create and one placement call. The agent vets the entire tree before a single node exists, so a refused tag or attribute anywhere in it rejects the whole call and nothing half-built reaches the page. One tree may hold up to 1000 nodes nested up to 32 deep. SVG drawing tags are created in the SVG namespace so they render. Reach a nested element afterwards with root.query(...).
handle.clone(options) answers a detached deep copy of a page element, host classes and attributes included, so a control can look like the page's own without you rebuilding its styling. Listeners are never copied. The copy passes the same sanitiser as written HTML, and the root's tag must be one create allows, so a copy of the page's own form is refused. Options: text replaces the whole content when it is a string, or sets the text of the first match of each selector when it is a { selector: text } map (a selector that matches nothing is an error); strip lists selectors whose matches are removed first; keepIds keeps id and for, which are dropped by default because a duplicate id breaks the page's own labels. What the copy lost is written to the log once as clone: …. dom.clone(selector, options) is query then clone, null when nothing matches.
dom.addStyle(css) appends a <style> element to the page's head and answers its handle. That is the right place for rules that mark up the page, alongside the manifest's style.css.
Placement is parent.append(child), parent.prepend(child), ref.before(node), ref.after(node), each taking a handle, and handle.remove(). A placement the browser refuses, such as a node into itself, rejects with cannot insert there.
Every element you create, clone, write as HTML, or add as a stylesheet is stamped data-rmx-owner with your remixlet's id. The stamp is written by the agent and cannot be set or removed by remixlet code. It is how the extension later tells your elements from the page's, which matters for the next section.
Changing elements#
| Call | Does |
|---|---|
setText(text) | sets textContent |
setHTML(html) | replaces the children with sanitised markup |
setAttr(name, value) / removeAttr(name) | an attribute |
setData(name, value) | the data- attribute for a dataset name |
hide() / show() | the hidden attribute |
setDisabled(bool) | the disabled attribute |
addClass(...names) / removeClass(...names) | class list edits; each answers the resulting class list |
toggleClass(...names) / toggleClass(name, force) | flips each, or adds and removes with a trailing boolean |
style({ prop: value }) | inline style; camelCase or kebab-case names, a trailing !important is honoured, an empty string removes the property |
setValue(value) | a form control's value, or a boolean for a checkbox or radio; fires input and change so frameworks notice |
click(), focus(), blur() | the obvious |
scrollIntoView(block) | block is start, center, end or nearest |
scrollTo(x, y, behavior) / scrollBy(x, y, behavior) | the element's own scroll; dom.scrollTo and dom.scrollBy are the window's |
Text and HTML writes are capped at 256 KB. A page's own <script>, <style>, <form>, <iframe>, <meta>, <link> and the other tags a remixlet cannot create are also off limits to attribute writes, whichever attribute is named, because changing one is a load or a navigation in disguise.
Marks on the page's own elements#
The policy draws one line through the write calls. An element you created or cloned is yours and takes any attribute or class. An element that belongs to the page takes only marks that carry your prefix. rmx.prefix is rmx- plus your remixlet's id, and on a page element an attribute must be named data-<prefix> or data-<prefix>-<name> and a class <prefix> or <prefix>-<name>. Every other name is refused, and the refusal spells the prefix out.
This includes hidden and disabled. Calling hide() on one of the page's elements is refused. The way to hide page content is a prefixed class on the element and a rule in your stylesheet, which also means a single body class can switch a whole feature on and off. setText, setHTML, style, setValue, click, remove and the placement calls are not marks and pass on any element.
The reason is bookkeeping. Every mark on a page names the remixlet that wrote it, so the extension can list marks left behind by a remixlet that no longer exists instead of mistaking them for page data. A remixlet that is disabled, paused, rolled back or deleted reloads every open matching tab, so its marks never outlive it.
Events#
const stop = handle.on("click", async (event) => { /* ... */ }, {
selector, preventDefault, stopPropagation, once, capture, passive,
});on binds a listener on the node the handle names and answers an unsubscribe function. The callback receives a plain object, never the event: type, target, currentTarget (both handles), key, code, altKey, ctrlKey, metaKey, shiftKey, button, clientX, clientY, value and checked for form controls, and defaultPrevented. Each field is present when the event carried it.
Because the callback runs in the sandbox, after a round trip, it cannot cancel anything. preventDefault and stopPropagation therefore only work when declared in the options, and the agent applies them synchronously in the page before forwarding the event. passive: true wins over preventDefault. selector delegates: the listener fires only when the event's target, or one of its ancestors inside the handle's node, matches, and that match becomes target.
dom.window.on(type, callback, options) and dom.window.off(type, callback) take window-level events only: scroll, resize, keydown, keyup, focus, blur and visibilitychange. The window.addEventListener stand-in routes here.
Listeners die with their node#
A listener is bound to a node, and pages redraw nodes. A site that re-serialises a container, so that a look-alike with no listener stands where your control was, leaves a keep that only checks for presence none the wiser. So the agent watches the nodes you listen on. When one that was in the document is not any more, its listeners are dropped, the handle becomes stale, and the sandbox hears about it once. The log gets a listener dropped: line naming the handle and the event types.
If the listener was bound inside the apply of an rmx.keep, that keep re-applies at once, even though ensure still passes, so a fresh listener lands on the fresh node. That is the whole reason to bind listeners inside apply and to write apply so it binds every time it runs: remove the look-alike or bind to it, and restore the state you own, because a re-serialised input comes back unchecked whatever the user set. A listener bound outside any keep is only logged, with that hint.
Watching the page#
const stop = dom.observe(({ deliveries }) => { /* read-only reaction */ }, {
root, childList, subtree, attributes, characterData,
});There is no MutationObserver in the sandbox. dom.observe is the replacement, and it delivers notices rather than mutation records. The agent watches root (the document by default) with the flags you give (all four by default) and folds raw deliveries into notices: the first after a quiet spell goes out at once, and whatever follows within 100 ms folds into one trailing notice, so a reaction is prompt and never more than ten a second. deliveries counts how many raw deliveries a notice stands for.
Each observer has a budget of 40 raw deliveries per second, and the agent reads how the budget was spent. Spent within 250 ms of a window's start, it is a burst your own writes fed back into the observer. The log gets a feedback-loop warning and deliveries are held until the next trailing notice, which bounds the loop to one burst a second. Spent slowly, it is a page that churns on its own. The log gets one informational notice saying so and the notices stay coalesced. The verification step that runs after the agent writes a remixlet reads those lines, so a loop is caught before you see it.
Use observe for reactions that only read. A state the page must keep, a badge that must stay mounted, a class that must stay on, belongs in rmx.keep, which is built on this observer and adds the discipline that stops a keep from fighting the page.
Page facts#
const loc = await dom.location(); // { href, origin, pathname, search, hash, title }
const vp = dom.viewport(); // { width, height, scrollX, scrollY }, no round trip
const fresh = await dom.viewportNow();The agent pushes a snapshot of the document, its URL, title, readyState and viewport, to the sandbox when it starts, on every navigation, on every readyState change and, throttled to one per 250 ms, on resize and scroll. dom.viewport(), the location stand-in and the window size fields read that snapshot synchronously. dom.location() and dom.viewportNow() ask the page.
What the page agent refuses#
The threat is a rogue remixlet, not a rogue page. The sandbox already stops remixlet code from talking to the network, so the policy closes the page as a channel out: an element whose address carries what the script just read, an injected frame or script, a navigation, a form submit. Everything else about the page is yours to change.
- Creating
form,iframe,frame,object,embed,script,base,meta,link,style,template,video,audio,source,track,picture,use,mathandnoscript. The allowed set is layout and text tags plus basic SVG drawing tags, and an unlisted tag is refused too. - Setting any
on*handler, andsrc,href,action,srcdoc,srcset,formaction,ping,poster,target,http-equiv,style,is,form,rel,downloadand the other attributes that load, run or redirect something. Thestyleattribute is refused in favour ofstyle(). - Two of those are mediated rather than refused:
hrefon a link andsrcon an image. The URL, resolved against the page, must be on the page's own origin, on a host in the remixlet'smatches, on a host the page itself already loads resources from, on a host a grantedfetch:ornetwork:observe:capability names, or be adata:imageURL.javascript:andblob:never resolve. - Any style, inline or in
addStyle, containingurl(,image-set(,@import,src(,expression(orbehavior:, in any spelling CSS would accept. - Markup handed to
setHTML,create { html }orcloneis parsed in an inert document first. Refused tags are dropped with their subtrees, refused attributes are stripped, URLs are vetted by the rule above, and comments go. What survives is imported into the page; nothing is re-parsed there. - Clicking an element under a link that resolves off the allowed hosts, a submit control of a form whose action does, or anything carrying
formaction. - An unprefixed attribute or class on a page element, and
data-rmx-owneranywhere.
A refused call rejects with an error starting refused: and the reason, and each distinct reason is logged once per sandbox. A page that stays unchanged after a write means read the log, not retry.
Trying it during a chat#
The agent's evaluate_js tool runs a snippet in a scratch sandbox on the current page: the same runtime, the same dom, the same policy, with no capabilities and no token, so nothing in rmx beyond log answers. A snippet is written the way remixlet code is, as an expression or a block ending in return, and any handle in its value comes back as that element's info(). Touching document or fetch throws the same message a remixlet would see. It is the way to try a dom sequence before writing it into a file.
Limits#
| Limit | Value |
|---|---|
| Handles per sandbox | 20000 |
| Calls in flight | 5000 |
| Selector length | 4096 characters |
queryAll and children | 500 handles |
waitFor | 10 s by default, 120 s at most |
info().text | 2000 characters |
text(), innerText(), html() | 64 KB |
setText, setHTML, create, addStyle | 256 KB per write |
create and clone trees | 1000 nodes, 32 levels deep |
computed | 100 properties per call |
| Mutation notices | one per 100 ms per observer after the first, 40 raw deliveries per second |
| Console capture | 500 characters per line, 100 lines per page load |
dom is part of the same published contract as rmx: within a bridge version it only grows, and a remixlet stamped with a version the installed extension does not support is parked as needing repair rather than run. The contract explains the versioning.