Plugins
Creating a plugin
A practical guide to building, testing, and shipping a Yapmod plugin.
A plugin is a small folder that Yapmod loads into the Yapper page. A stylesheet alone can restyle the interface; adding a script lets the plugin react to the app, store settings, and inspect network traffic.
This guide builds up a plugin one piece at a time, then ends with a complete example that watches live HTTP and WebSocket activity.
Start with a package
Every plugin folder looks like this:
my-plugin/
βββ manifest.json
βββ plugin.css
βββ plugin.js # optional
Start with the manifest and stylesheet:
{
"id": "quiet-scrollbars",
"name": "Quiet Scrollbars",
"version": "1.0.0",
"author": "Your name",
"description": "Thin, low-contrast scrollbars.",
"api": 1,
"stylesheet": "plugin.css"
}
The manifest is the identity of the package.
idis required and must match the folder name. Use 1β24 letters, digits,_, or-, starting with a letter or digit.nameis required and is shown in the plugin list.apiis required. The current plugin API version is1.version,author,description, andlicenseare optional metadata.tagsare optional search words used by the Community gallery.settingsdeclares the user-configurable options that appear in the plugin settings dialog.stylesheetmust beplugin.csswhen present.scriptmust beplugin.jswhen present.
Now write the stylesheet. It is ordinary CSS, but it cannot load external
resources: @import, url(), CSS escapes, and similar constructs are
rejected. A plugin with no script is a perfectly valid, useful plugin.
/* plugin.css */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
border-radius: 999px;
background: color-mix(in srgb, var(--ym-text) 22%, transparent);
}
To try it, open Settings β Yapmod β Plugins, choose Import plugin, and select the folder. Yapmod validates the package and shows a consent dialog before enabling it.
Add behavior with a script
Add "script": "plugin.js" to the manifest. Yapmod loads plugin.js as an ES
module and uses its default export, which must provide an activate(api)
function:
export default {
activate(api) {
document.documentElement.dataset.quietScrollbars = "on";
api.log.info("activated");
return () => {
delete document.documentElement.dataset.quietScrollbars;
};
},
};
activate may be asynchronous, and the cleanup function it returns may also be
asynchronous. Use the api argument rather than relying on globals; it is
scoped to your plugin.
plugin.js is a single-file module. Relative imports cannot be resolved
because the package is loaded from its source rather than from a folder on a
web server, so keep the plugin self-contained.
Register cleanup as you go
A plugin can create many resources: timers, event listeners, subscriptions,
DOM nodes, and styles. Returning one large cleanup closure quickly becomes
fragile. api.onCleanup registers one callback per resource, in the order the
resource is created:
export default {
activate(api) {
const timer = setInterval(refresh, 5000);
api.onCleanup(() => clearInterval(timer));
const stop = api.settings.onChange("show-badge", refresh);
api.onCleanup(stop);
},
};
Callbacks run in reverse order when the plugin is disabled. They also run when
activate throws after registering them, which prevents a broken activation
from leaving listeners behind. Returning a cleanup function still works, but
api.onCleanup is easier to reason about for plugins with several moving
parts.
Cleanup is not optional. A disabled plugin must not keep timers running, listen for network events, or leave elements in the page.
Use the plugin API
Every plugin receives the same api object, with identity, storage, settings,
styles, logging, and network inspectors. api.apiVersion lets you feature
detect newer APIs. api.id and api.manifest identify the running package.
Logging
Use the namespaced logger instead of console.log directly:
api.log.debug("state", state);
api.log.info("ready");
api.log.warn("fallback used");
api.log.error("request failed", error);
Messages are prefixed with [plugin:<id>], which makes plugin output easy to
find in the browser console.
Storage
Storage is namespaced by plugin id. Keys use the same 1β24 character format as plugin ids, and values must be JSON-serializable. Writes are persisted in the background.
const launches = api.storage.get("launches", 0);
api.storage.set("launches", launches + 1);
api.storage.keys(); // string[]
const stop = api.storage.onChange("launches", (value) => {
api.log.debug("launches changed", value);
});
api.onCleanup(stop);
Do not use storage as a message log. Store small summaries or preferences, not copies of requests, headers, or message bodies.
Settings
Settings are declared in the manifest, so users can change plugin behavior without editing code. Yapmod renders them in the plugin settings dialog and saves them when the user chooses Save.
Four setting types are supported:
Toggle β a simple on/off switch:
{
"id": "show-badge",
"label": "Show activity badge",
"description": "Display a small status badge.",
"type": "toggle",
"default": true
}
Text β a free-form text input:
{
"id": "custom-greeting",
"label": "Greeting text",
"description": "Text shown when the app starts.",
"type": "text",
"default": "Hello!"
}
File β a file picker with a browse button. The value is a base64 data URI
for the file the user selected (see Loading local files).
The optional accept field hints at the expected file type (e.g.
"image/*"):
{
"id": "background-image",
"label": "Background Image",
"description": "Select an image to use as the app background.",
"type": "file",
"default": null,
"accept": "image/*"
}
Color β a color picker with a hex text input:
{
"id": "accent-color",
"label": "Accent Color",
"description": "Override the default accent color.",
"type": "color",
"default": "#ff6b4a"
}
Read settings through the API. The returned value matches the setting type
(boolean for toggle, string or null for text/file/color):
api.settings.get("show-badge"); // boolean
api.settings.get("background-image"); // data URI string or null
api.settings.all(); // { "show-badge": true, "background-image": "data:image/png;base64,..." }
const stop = api.settings.onChange("show-badge", (value) => {
badge.hidden = !value;
});
api.onCleanup(stop);
Settings are also exposed as data-yapmod-plugin-{pluginId}-{settingId}
attributes on the <html> element. Toggle settings set the attribute to
"true" when enabled; other types set the attribute to the string value.
This lets CSS react to settings without JavaScript:
/* Toggle: attribute presence */
html[data-yapmod-plugin-my-plugin-show-badge="true"] .badge {
display: block;
}
/* Color: attribute value */
html[data-yapmod-plugin-my-plugin-accent-color] {
--ym-accent: attr(data-yapmod-plugin-my-plugin-accent-color);
}
Reading or subscribing to an undeclared setting throws, so typos fail loudly.
Styles
The package stylesheet is applied automatically. If a plugin needs to add CSS
at runtime, api.styles.add validates the CSS and returns a cleanup function:
const removeStyle = api.styles.add(`
.my-plugin-highlight {
outline: 2px solid var(--ym-accent);
}
`);
api.onCleanup(removeStyle);
Banners, notifications, and modals
For short feedback, show a dismissible banner:
api.banner("Network activity is being watched");
System notifications are useful for events that happen while the app is in the background:
const notification = api.notify.show({
title: "Network activity",
body: "A new request was observed.",
});
notification.close();
For anything that needs a real dialog, use the modal API. It returns a modal
handle with close() and destroy():
const modal = api.modal.show({
title: "Network activity",
description: "Live HTTP and WebSocket counters.",
content: "Open the browser console for details.",
buttons: [{ label: "Close", variant: "secondary" }],
});
Loading local files
When a plugin declares a file setting, the user picks a file through a
native dialog and the plugin receives a base64 data URI (e.g.
"data:image/png;base64,iVBOR...") as the setting value. The file is read at
pick time, so there is no runtime file-reading command.
To use the data URI in CSS or image elements, convert it to a blob URL:
// The file setting value is a data URI
const dataUri = api.settings.get("background-image");
// "data:image/jpeg;base64,/9j/4AAQSk..."
// Convert to a blob URL for CSS url() or <img> src
const url = await api.file.createBlobUrl(dataUri);
// "blob:https://www.yapin.gg/..."
The blob URL is automatically revoked when the plugin is disabled, so manual cleanup is optional. For explicit control:
api.file.revokeObjectURL(url);
This is useful when a plugin replaces an image and wants to free the previous blob immediately rather than waiting for deactivation.
The data URI is stored in the pluginβs settings. Very large files will increase the settings size and the startup payload. A file settingβs data URI may be at most 8 MiB, which corresponds to a roughly 6 MiB file. Other plugin setting values are limited to 64 KiB.
Watch network traffic
Yapmod installs its HTTP and WebSocket inspectors before the app starts. A plugin can subscribe to live events and, where supported, modify traffic before it is sent or delivered.
The most important rule is that the inspectors keep no history. A subscriber receives each event once, as it happens. If you need a log, keep the data in your plugin and release it during cleanup. This is deliberate: a plugin should only retain what it actually needs.
Observe HTTP
api.http.outgoing fires when a request is about to be sent.
api.http.exchanges fires after the response arrives and includes both sides
of the exchange.
const stopOutgoing = api.http.outgoing.subscribe((request) => {
api.log.debug("sending", request.method, request.url);
});
const stopExchanges = api.http.exchanges.subscribe((exchange) => {
api.log.debug(
"completed",
exchange.method,
exchange.url,
`${exchange.status} ${exchange.statusText}`,
`${exchange.duration}ms`,
);
});
api.onCleanup(stopOutgoing);
api.onCleanup(stopExchanges);
An outgoing request has url, method, requestHeaders, requestBody, and
timestamp. An exchange adds status, statusText, responseHeaders,
responseBody, and duration. Header values are arrays of [name, value]
pairs.
Modify HTTP requests
Register an interceptor on api.http.outgoing. An interceptor receives the
current request and may return a patch containing url, method, headers,
or body. Returning undefined leaves the request unchanged.
const stop = api.http.outgoing.use(async (request) => {
if (!request.url.includes("/rest/v1/messages")) {
return undefined;
}
return {
headers: [...request.requestHeaders, ["x-plugin-network", "on"]],
};
});
api.onCleanup(stop);
A few rules matter here:
- Interceptors may be asynchronous and run in registration order.
headersreplaces the complete header set. Start fromrequest.requestHeaderswhen you only want to add or change one header.bodyreplaces the request body. Usebody: nullto send no body.- If an interceptor throws or returns an invalid patch, the entire chain is discarded and the original request is sent.
- Interceptors affect every matching
fetch()while they are registered. Remove them during cleanup.
Modify HTTP responses
api.http.responses.use runs after the response arrives but before the caller
receives it. Return a new Response to replace the original, or undefined to
leave it alone. Response interceptors may also be asynchronous.
const stop = api.http.responses.use((exchange) => {
if (!exchange.url.includes("/rest/v1/messages") || exchange.status !== 200) {
return undefined;
}
const messages = JSON.parse(exchange.responseBody);
messages.push({ id: "example", content: "added by a plugin" });
return new Response(JSON.stringify(messages), {
status: exchange.status,
headers: Object.fromEntries(exchange.responseHeaders),
});
});
api.onCleanup(stop);
If any response interceptor fails, every modification in that chain is discarded and the original response is delivered.
Response bodies are decoded for text, JSON, and XML content. Binary bodies are
copied into an ArrayBuffer. Blob bodies are represented as
{ __blob: true, type, size } because they cannot be read synchronously.
Streaming responses such as text/event-stream are delivered immediately and
report responseBody as undefined; buffering them would stop the stream from
working.
Observe WebSocket messages
WebSocket messages are available in both directions:
const stopIncoming = api.webSocket.incoming.subscribe((message) => {
api.log.debug("received", message.url, message.data);
});
const stopOutgoing = api.webSocket.outgoing.subscribe((message) => {
api.log.debug("sent", message.url, message.data);
});
api.onCleanup(stopIncoming);
api.onCleanup(stopOutgoing);
A message has url, data, and timestamp. Like HTTP bodies, binary data is
copied into an ArrayBuffer, and Blob data appears as { __blob: true, type, size }.
Modify WebSocket messages
WebSocket interceptors run synchronously because the message is delivered
immediately. Return the replacement payload, or undefined to keep the current
one.
Incoming messages can be changed before application listeners see them:
const stop = api.webSocket.incoming.use((message) => {
if (typeof message.data === "string" && message.data.includes("secret")) {
return "[redacted]";
}
return undefined;
});
api.onCleanup(stop);
Outgoing messages can be changed before send():
const stop = api.webSocket.outgoing.use((message) => {
if (typeof message.data === "string" && message.data.includes("spam")) {
return "";
}
return undefined;
});
api.onCleanup(stop);
An interceptor may return a string, ArrayBuffer, ArrayBufferView, or Blob.
If it throws or returns an invalid type, the whole chain is discarded and the
original payload is delivered. Incoming interceptors cannot be asynchronous;
message delivery is synchronous.
Know the boundaries
The network inspectors are intentionally narrow:
- Only
fetch()is inspected. XMLHttpRequest and other transports are not. - There is no built-in message history, replay, or active-request list.
- Connection lifecycle events such as WebSocket open and close are not exposed.
- Incoming WebSocket interception is synchronous.
- Interceptors are global to the page while registered, so always remove them when the plugin is disabled.
A complete example
The example below counts live HTTP and WebSocket activity without storing messages. Its manifest declares three toggles:
"settings": [
{
"id": "show-badge",
"label": "Show activity badge",
"description": "Display a small badge with live HTTP and WebSocket counts.",
"type": "toggle",
"default": true
},
{
"id": "log-http",
"label": "Log HTTP exchanges",
"description": "Print completed HTTP exchanges to the browser console.",
"type": "toggle",
"default": false
},
{
"id": "log-websocket",
"label": "Log WebSocket messages",
"description": "Print incoming and outgoing WebSocket messages to the browser console.",
"type": "toggle",
"default": false
}
]
The module watches the inspectors as live streams, keeps only counters, and cleans up every subscription and DOM node:
export default {
activate(api) {
let sessionHttp = 0;
let sessionWebSocket = 0;
let badge = null;
const render = () => {
if (!api.settings.get("show-badge")) {
badge?.remove();
badge = null;
return;
}
if (!badge) {
badge = document.createElement("div");
badge.id = "my-plugin-network-badge";
badge.setAttribute("role", "status");
document.body.append(badge);
}
badge.textContent =
`HTTP ${sessionHttp} Β· WS ${sessionWebSocket}`;
};
api.onCleanup(api.http.exchanges.subscribe((exchange) => {
sessionHttp += 1;
if (api.settings.get("log-http")) {
api.log.debug("HTTP", exchange.method, exchange.url, exchange.status);
}
render();
}));
api.onCleanup(api.webSocket.incoming.subscribe((message) => {
sessionWebSocket += 1;
if (api.settings.get("log-websocket")) {
api.log.debug("WS in", message.url);
}
render();
}));
api.onCleanup(api.webSocket.outgoing.subscribe((message) => {
sessionWebSocket += 1;
if (api.settings.get("log-websocket")) {
api.log.debug("WS out", message.url);
}
render();
}));
api.onCleanup(api.settings.onChange("show-badge", render));
api.onCleanup(() => badge?.remove());
render();
},
};
Add the matching package stylesheet and manifest fields, install the folder
locally, and the plugin is complete. When it is disabled, api.onCleanup
removes the badge and unsubscribes from all three channels.
Test and ship
The quickest test loop is a local import:
- Create the plugin folder with a valid
manifest.jsonandplugin.css. - Open Settings β Yapmod β Plugins and choose Import plugin.
- Read the consent warning, enable the plugin, and inspect the browser console.
- Change settings and confirm the plugin reacts without reloading.
- Disable the plugin and check that its UI, timers, and listeners are gone.
- Re-enable it and make sure it still behaves correctly.
Keep the package small and predictable. Stylesheets and scripts are limited to 512 KB each, a manifest can declare at most 12 settings, and every ID must use the plugin key format. Yapmod hashes the package contents for consent, so changing the CSS or JavaScript requires the user to approve the plugin again.
To publish a plugin in the Community catalog, add its folder under
community-plugins/ with the same structure. The website build validates the
manifest, stylesheet, and script before publishing the package.
Verification is separate from publishing. The Yapmod owners add reviewed plugin
ids to community-plugins/verified.json; the build injects the badge into the
catalog from that list. Do not add a verified field to a manifest β it is
rejected as an unknown field.
[!WARNING] JavaScript plugins are trusted page code, not sandboxed extensions. They run in the Yapper page with full access to its DOM and JavaScript realm. Test locally and only distribute code you expect users to trust.