GM API Support Across Userscript Managers
About the author: Tooling engineer, formerly at Qualcomm and MediaTek. Builds MonkeyMom, an AI userscript factory for desktop and mobile managers.
A probe of 57 GM APIs across four userscript managers — Violentmonkey, Via, Stay for Safari and Userscripts — plus what MonkeyMom implements itself.
Every userscript manager implements the GM API from its own reading of the spec. The names look the same, so a script that works in one manager is assumed to work in the others. In practice it often does not, and the failures are quiet: a function exists but returns nothing, a callback never fires, a listener has no id so it can never be removed.
MonkeyMom has to run scripts it did not write, on managers it does not control. Before the child-script bridge could be designed, we needed to know what the ground actually looks like. So we probed 57 API names across four managers.
How the probe works
What each probe records
| What is checked | How | What failure looks like |
|---|---|---|
| Existence and type | typeof on the identifier | undefined, or a function that is a silent no-op |
| Call result | The call is made and the value captured | A promise, an element, undefined, or a thrown error |
| Async callbacks | Fire the call, wait with a timeout | The callback never fires, or fires after the timeout |
| Return-value type | The resolved value is inspected | A promise where an element is expected, or the reverse |
Why documentation is not enough
The documentation describes intent; what a script sees is the implementation. One name can behave four different ways.
| API | Violentmonkey | Via | Stay for Safari |
|---|---|---|---|
GM.openInTab | handle with close() | undefined | Promise resolving to a tab object with no close() |
GM.addStyle | Promise → <style> | <style> element | selector string |
GM_xmlhttpRequest | callback style | callback style | callback style |
GM_listValues | this script's keys | keys from every script | this script's keys |
Reading the docs would have produced one assumption and three surprises.
Probe data
The probe covers 57 API names. Below are the per-manager summary, the support matrix, the return-type differences and what MonkeyMom synthesizes, kept as a reference for writing scripts.
Summary
Totals per manager, with the full name list of every API that passed.
| Manager | Passed / 57 | APIs that passed |
|---|---|---|
| 暴力猴 | 48 / 57 | GM_setValueGM_getValueGM_deleteValueGM_listValuesGM_setValuesGM_getValuesGM_deleteValuesGM_addValueChangeListenerGM_removeValueChangeListenerGM_addStyleGM_addElementGM_openInTabGM_setClipboardGM_notificationGM_downloadGM_registerMenuCommandGM_unregisterMenuCommandGM_xmlhttpRequestGM_webRequestGM_cookie.setGM_cookie.listGM_cookie.deleteGM_audio.removeStateChangeListenerGM_getResourceTextGM_getResourceURLGM_infoGM_logunsafeWindowGM.infoGM.setValueGM.getValueGM.deleteValueGM.setValuesGM.getValuesGM.deleteValuesGM.listValuesGM.addStyleGM.addElementGM.notificationGM.downloadGM.registerMenuCommandGM.unregisterMenuCommandGM.getResourceUrlGM.getResourceTextGM.openInTabGM.closeTabGM.setClipboardGM.xmlHttpRequest |
| Via | 41 / 57 | GM_setValueGM_getValueGM_deleteValueGM_listValuesGM_setValuesGM_getValuesGM_deleteValuesGM_addStyleGM_addElementGM_openInTabGM_setClipboardGM_notificationGM_downloadGM_registerMenuCommandGM_unregisterMenuCommandGM_xmlhttpRequestGM_webRequestGM_audio.removeStateChangeListenerGM_getResourceTextGM_getResourceURLGM_infoGM_logunsafeWindowGM.infoGM.setValueGM.getValueGM.deleteValueGM.setValuesGM.getValuesGM.deleteValuesGM.listValuesGM.addStyleGM.addElementGM.notificationGM.registerMenuCommandGM.unregisterMenuCommandGM.getResourceUrlGM.openInTabGM.closeTabGM.setClipboardGM.xmlHttpRequest |
| Stay for Safari | 44 / 57 | GM_setValueGM_getValueGM_deleteValueGM_listValuesGM_addValueChangeListenerGM_removeValueChangeListenerGM_saveTabGM_getTabGM_getTabsGM_addStyleGM_addElementGM_openInTabGM_setClipboardGM_notificationGM_downloadGM_registerMenuCommandGM_unregisterMenuCommandGM_xmlhttpRequestGM_webRequestGM_cookie.setGM_cookie.listGM_cookie.deleteGM_audio.removeStateChangeListenerGM_getResourceTextGM_getResourceURLGM_infoGM_logunsafeWindowGM.infoGM.setValueGM.getValueGM.deleteValueGM.listValuesGM.addStyleGM.addElementGM.notificationGM.registerMenuCommandGM.unregisterMenuCommandGM.getResourceUrlGM.openInTabGM.closeTabGM.setClipboardGM.saveTabGM.xmlHttpRequest |
| Userscripts for Safari | 22 / 57 | GM_setValueGM_getValueGM_deleteValueGM_listValuesGM_saveTabGM_addStyleGM_openInTabGM_setClipboardGM_xmlhttpRequestGM_infoGM.infoGM.setValueGM.getValueGM.deleteValueGM.listValuesGM.addStyleGM.openInTabGM.closeTabGM.setClipboardGM.saveTabGM.getTabGM.xmlHttpRequest |
Support matrix
| API | Violentmonkey | Via | Stay for Safari | Userscripts |
|---|---|---|---|---|
| GM_setValue | ✅ | ✅ | ✅ | ✅ |
| GM_getValue | ✅ | ✅ | ✅ | ✅ |
| GM_deleteValue | ✅ | ✅ | ✅ | ✅ |
| GM_listValues | ✅ | ✅ | ✅ | ⚠️ returns {} (GM.listValues works) |
| GM_setValues | ✅ | ✅ | ❌ | ❌ |
| GM_getValues | ✅ | ✅ | ❌ | ❌ |
| GM_deleteValues | ✅ | ✅ | ❌ | ❌ |
| GM_addValueChangeListener | ✅ | ❌ | ✅ | ❌ |
| GM_removeValueChangeListener | ✅ | ❌ | ✅ | ❌ |
| GM_saveTab | ❌ | ❌ | ✅ | ✅ |
| GM_getTab | ❌ | ❌ | ✅ | ⚠️ timeout |
| GM_getTabs | ❌ | ❌ | ✅ | ❌ |
| GM_addStyle | ✅ | ✅ | ✅ | ✅ |
| GM_addElement | ✅ | ✅ | ✅ | ❌ |
| GM_openInTab | ✅ | ✅ | ✅ | ✅ |
| GM_setClipboard | ✅ | ✅ | ✅ | ✅ |
| GM_notification | ✅ | ✅ | ✅ | ❌ |
| GM_download | ✅ | ✅ | ✅ | ❌ |
| GM_registerMenuCommand | ✅ | ✅ | ✅ | ❌ |
| GM_unregisterMenuCommand | ✅ | ❌ | ✅ | ❌ |
| GM_xmlhttpRequest | ✅ | ✅ | ✅ | ✅ |
| GM_webRequest | ⚠️ no-op | ⚠️ no-op | ⚠️ no-op | ❌ |
| GM_cookie.set | ✅ | ❌ | ✅ | ❌ |
| GM_cookie.list | ✅ | ❌ | ✅ | ❌ |
| GM_cookie.delete | ✅ | ❌ | ✅ | ❌ |
| GM_audio.setMute | ❌ | ❌ | ❌ | ❌ |
| GM_audio.getState | ❌ | ❌ | ❌ | ❌ |
| GM_audio.addStateChangeListener | ❌ | ❌ | ❌ | ❌ |
| GM_audio.removeStateChangeListener | ⚠️ joint test | ⚠️ joint test | ⚠️ joint test | ❌ |
| GM_getResourceText | ✅ | ✅ | ✅ | ❌ |
| GM_getResourceURL | ✅ | ✅ | ✅ | ❌ |
| GM_info | ✅ | ✅ | ✅ | ✅ |
| GM_log | ✅ | ✅ | ✅ | ❌ |
| unsafeWindow | ✅ | ✅ | ✅ | ❌ |
| GM.info | ✅ | ✅ | ✅ | ✅ |
| GM.setValue | ✅ | ✅ | ✅ | ✅ |
| GM.getValue | ✅ | ✅ | ✅ | ✅ |
| GM.deleteValue | ✅ | ✅ | ✅ | ✅ |
| GM.setValues | ✅ | ✅ | ❌ | ❌ |
| GM.getValues | ✅ | ✅ | ❌ | ❌ |
| GM.deleteValues | ✅ | ✅ | ❌ | ❌ |
| GM.listValues | ✅ | ✅ | ✅ | ✅ |
| GM.addStyle | ✅ | ✅ | ✅ | ✅ |
| GM.addElement | ✅ | ✅ | ✅ | ❌ |
| GM.notification | ✅ | ✅ | ✅ | ❌ |
| GM.download | ✅ | ❌ | ❌ | ❌ |
| GM.cookie | ⚠️ timeout | ❌ | ⚠️ timeout | ❌ |
| GM.registerMenuCommand | ✅ | ⚠️ cannot unregister | ✅ | ❌ |
| GM.unregisterMenuCommand | ✅ | ❌ | ✅ | ❌ |
| GM.getResourceUrl | ✅ | ✅ | ✅ | ❌ |
| GM.getResourceText | ✅ | ❌ | ❌ | ❌ |
| GM.openInTab | ✅ | ✅ | ✅ | ✅ |
| GM.closeTab | ✅ | ✅ | ✅ | ✅ |
| GM.setClipboard | ✅ | ✅ | ✅ | ✅ |
| GM.saveTab | ❌ | ❌ | ✅ | ✅ |
| GM.getTab | ❌ | ❌ | ⚠️ throws | ✅ |
| GM.xmlHttpRequest | ✅ | ✅ | ✅ | ✅ |
Totals: Violentmonkey 48/57, Stay for Safari 44/57, Via 41/57, Userscripts 22/57.
Return types
What a function returns differs as much as whether it exists. For cross-manager scripts this table matters more than the support matrix.
| API | Violentmonkey | Via | Stay for Safari |
|---|---|---|---|
| GM.addStyle | Promise → <style> | Promise → <style> | Promise → selector string |
| GM_addStyle | Promise → <style> | <style> element | <style> element |
| GM.addElement | Promise → <div> | Promise → <div> | <div> directly |
| GM_addElement | Promise → <div> | <div> element | <div> element |
| GM.xmlHttpRequest | Promise (never await) | undefined | {abort} object |
| GM_xmlhttpRequest | {abort} object | undefined | {abort} object |
| GM.openInTab | {onclose, closed, close} | undefined | Promise → tab object |
| GM_openInTab | {onclose, closed, close} | undefined | undefined |
| GM_download | {abort} object | no callback | undefined |
GM.* versus GM_
New scripts should prefer GM.*; the synchronous form only matters for old scripts.
| Async form | Sync form |
|---|---|
| GM.setValue | GM_setValue |
| GM.getValue | GM_getValue |
| GM.deleteValue | GM_deleteValue |
| GM.listValues | GM_listValues |
| GM.addStyle | GM_addStyle |
| GM.addElement | GM_addElement |
| GM.setClipboard | GM_setClipboard |
| GM.xmlHttpRequest | GM_xmlhttpRequest |
| GM.getResourceUrl | GM_getResourceURL |
| GM.info | GM_info |
| GM.openInTab | GM_openInTab |
| — | GM_download |
| — | GM_getResourceText |
What MonkeyMom synthesizes
These APIs do not depend on the manager; MonkeyMom provides them inside the child-script environment.
| API | MonkeyMom implementation |
|---|---|
| GM_getResourceText | Reads the prefetched @resource cache |
| GM_getResourceURL | Wraps cached text in a page-side blob URL |
| GM.getResourceUrl | Cache hit returns immediately, miss fetches through the bridge |
| GM_registerMenuCommand | Registers a button under the script row in the panel |
| GM_unregisterMenuCommand | Removes the button by command id |
| GM_notification | In-page toast with onclick / ondone |
Deliberately not implemented: GM_webRequest, unsafeWindow, GM.closeTab, GM_audio.*, GM.cookie.
Where the managers actually differ
The spread is not random. It clusters by feature family, and each family fails in its own way.
Storage
| Behaviour | Violentmonkey | Via | Stay for Safari | Userscripts |
|---|---|---|---|---|
Basic four (get / set / delete / list) | ✅ | ✅ | ✅ | ✅ |
Batch forms (setValues / getValues / deleteValues) | ✅ | ✅ | ❌ | ❌ |
GM.listValues returns this script's keys | ✅ | ❌ shared across scripts | ✅ | ✅ |
GM_listValues (classic sync form) | ✅ | ✅ | ✅ | ⚠️ returns {} |
Via's storage is shared across all scripts: two scripts using the key state overwrite each other unless they namespace it by hand. On Userscripts the split runs inside a single manager: the async GM.listValues returns the real key names, while the classic GM_listValues returns {} — worse than absent, because it looks supported.
Listeners and tabs
| Behaviour | Violentmonkey | Via | Stay for Safari |
|---|---|---|---|
GM_addValueChangeListener returns an id | ✅ | ❌ undefined | ✅ |
GM_removeValueChangeListener accepts that id | ✅ | ❌ | ✅ |
GM_saveTab / GM_getTab / GM_getTabs | ❌ | ❌ | ✅ |
A listener without an id is registered forever. That alone is enough reason for the bridge not to depend on the manager's listener API.
Style and element injection
| Behaviour | Violentmonkey | Via | Stay for Safari | Userscripts |
|---|---|---|---|---|
GM_addStyle | ✅ | ✅ | ✅ | ✅ |
GM_addElement | ✅ | ✅ | ✅ | ❌ missing |
GM.addStyle returns | Promise → <style> | Promise → <style> | Promise → selector string | not probed |
GM_addStyle returns | Promise → <style> | <style> element | <style> element | not probed |
GM.addElement returns | Promise → <div> | Promise → <div> | <div> directly | not probed |
Userscripts has no GM_addElement at all. That is fatal for scripts that load code dynamically on a strict Content Security Policy: without the manager's help there is no DOM path around CSP.
Network, download, clipboard and tabs
| Behaviour | Violentmonkey | Via | Stay for Safari |
|---|---|---|---|
GM_xmlhttpRequest | ✅ callback style | ✅ callback style | ✅ callback style |
GM.download | ✅ returns {abort} | ❌ missing; callbacks never fire | ✅ returns undefined |
GM.setClipboard | ✅ | ✅ | ✅ |
GM.openInTab opens a background tab | ✅ | ✅ | ✅ |
GM.openInTab returns | handle with close() | undefined | Promise → tab object |
Menus, notifications and cookies
| Behaviour | Violentmonkey | Via | Stay for Safari |
|---|---|---|---|
GM_registerMenuCommand | ✅ | ✅ | ✅ |
GM_unregisterMenuCommand | ✅ | ❌ | ✅ |
GM_notification actually shows | ✅ | ✅ | ✅ (Edge needs the permission) |
GM.cookie.set / list / delete | ✅ | ❌ | ✅ |
GM_getResourceText when the resource is missing | undefined | null | undefined |
What MonkeyMom implements itself
The bridge runs the child script as an injected script with a synthetic GM object. What the manager does reliably is passed through; what is missing, broken or inconsistent is implemented by MonkeyMom.
| API | What the managers do | What MonkeyMom does |
|---|---|---|
GM_getResourceText / GM_getResourceURL | Need an install step to download @resource files | Parses the metadata, prefetches through GM.xmlHttpRequest with a 15 s cap, reads the cache synchronously |
GM.getResourceUrl | Same split, plus null versus undefined | Cache hit resolves at once; a miss fetches through the bridge |
GM_registerMenuCommand / GM_unregisterMenuCommand | No menu to click on mobile; Via cannot unregister | A button under the script row; the returned id really unregisters |
GM_notification | Depends on the browser notification permission | In-page toast, both call forms, onclick and ondone supported |
GM_getValue / GM_setValue / GM_deleteValue / GM_listValues | Per-script in some managers, global in Via | Per-script storage by script id, synchronous cache plus async persist |
GM_addElement on WebKit | Inserted elements are still blocked by the page CSP | Reuses the page nonce and falls back to GM_addStyle for styles |
Injection that survives strict CSP
On WebKit — Safari, Stay, and every browser on iOS — extension-inserted elements do not bypass the page CSP. The bridge reads the page's own nonce, watches the DOM for one appearing later, and probes whether inline script and style are blocked. Styles fall back to GM_addStyle, which is exempt; scripts warn when there is no nonce to reuse.
What it deliberately does not support
| API | Why it is left out |
|---|---|
GM_webRequest | The child script already runs in the main world; no interception layer is needed |
unsafeWindow | In the main world the page's window is simply window |
GM.closeTab | Missing or broken in every manager probed |
GM_audio.* | Unimplemented everywhere it was probed |
GM.cookie | An injected script can only touch cookies that are not HttpOnly; full access would be a lie |
Writing scripts that survive every manager
Use the narrow path
Prefer GM.getValue and friends over the sync form, GM.xmlHttpRequest in callback style and never read its return value, and GM_addStyle over GM.addStyle when the element handle does not matter. If you need an element back from GM.addElement, wrap it in Promise.resolve() before awaiting.
Keep storage namespaced
Even inside MonkeyMom, where isolation is guaranteed, prefixing keys costs nothing and makes a script portable to managers that share storage between scripts.
Export is the portability test
A child script can be exported as a standard userscript: the header carries @grant lines for the union of the allow-list and the compatibility list, and @resource declarations travel with it. If the exported file runs in Tampermonkey, Violentmonkey or Stay, the portability work paid off.