Shockless Documentation

Plugin Authoring

Index

# Shockless Plugin Authoring

This is the practical guide for creating, installing, validating, and shipping

Shockless plugins.

Shockless plugins are folder-based modules. Each plugin has a manifest named

shockless.plugin.json, an entry file, and optional supporting files. The Plugin

Manager validates the manifest, installs the folder, persists enabled state,

shows the plugin in the rail when enabled, and generates relay packet-hook

policy from enabled plugin permissions.

Built-in app plugins are also folder-based, but they use TypeScript metadata

files instead of user manifests:


src/plugins/<plugin-id>/plugin.ts

The built-in index is src/plugins/builtins.ts; the central

src/plugins/registry.ts only normalizes, sorts, and derives fallback

permissions. Do not add new built-in plugin definition objects directly to

registry.ts.

The portable package also ships readable premade module source folders under:


plugins/_premade-modules/<module-id>/

Each premade module is an installable user-plugin example with an id prefixed

by premade-, for example premade-room. These folders mirror the built-in

module permissions and event/API patterns so plugin authors can inspect real

module-style code without colliding with the native built-in panels.

Current Capability

Usable now:

  • Create a plugin from the bundled template.
  • Install an existing plugin folder.
  • Enable or disable optional plugins.
  • Enable or disable each declared UI surface.
  • Show user plugins in the plugin rail.
  • Show a schema-rendered panel for every plugin. If a plugin omits custom layout JSON, Shockless generates a safe default preview and surface layout from the manifest metadata.
  • Persist plugin and surface state.
  • Validate plugin folders before install.
  • Refuse obvious local private files in plugin folders.
  • Run enabled user plugin.js entries in a restricted renderer Worker host.
  • Deliver permission-filtered room, chat, session, runtime, and packet events.
  • Provide plugin-scoped storage APIs.
  • Provide readable helper groups such as subscriptions, runtime, room, session.onSelected(), and chat.onMessage().
  • Provide room chat helpers through chat.send(), chat.say(), chat.shout(), and chat.whisper().
  • Provide packet-backed avatar controls through avatar.*, including

coordinate walking and walkToItem() item resolution by id/name/class text.

  • Provide packet-backed domain helper APIs through groups such as social.*, furni.*, wallItems.*, and other typed action namespaces listed in the API reference.
  • Allow plugins to define local custom events from raw packet observations with

events.defineFromPacket().

  • Pass enabled packet permissions into each embedded relay process.
  • Filter sensitive client packets from plugin relay policy unless explicitly

granted.

Reserved for later host phases:

  • Registering custom console commands from plugin code.
  • Blocking, replacing, or mutating live relay traffic from packet event return

values. packets.send() is implemented for explicit validated

client-to-server packet sends.

  • Custom command dispatch from registered plugin commands. Manifest command

metadata can be documented today, but runtime registration still returns a

reserved result.

Schema UI is implemented through manifests and restricted runtime updates. Plugin code may update host-rendered schema surfaces with api.ui.registerSurface(), api.ui.updateSurface(), and api.ui.setValue(), then receive interactions through api.ui.onAction(). It cannot mount React components, inject HTML, or access the DOM directly.

Do not build plugins that depend on unrestricted Electron or Node access. User

plugin entries run in a Worker and only receive the restricted api host object passed to activate(api).

Create A Plugin

  1. Open Plugin Manager.
  2. Enter a lowercase plugin id, for example room-tools.
  3. Enter a display name, for example Room Tools.
  4. Click Create From Template.
  5. Click Open Folder.
  6. Edit the created folder.
  7. Click Reload Plugins.
  8. Enable the plugin if it is disabled.

Plugin ids must:

  • use lowercase letters, numbers, and hyphens
  • start with a letter or number
  • be 2 to 64 characters
  • not match a built-in plugin id

Good ids:

  • room-tools
  • packet-notes
  • visitor-helper

Invalid ids:

  • Room Tools
  • room_tools
  • plugin!
  • settings

Install A Plugin

Use Install From Folder and select a folder containing

shockless.plugin.json. Shockless validates the selected folder, copies it into the

managed user plugin root, enables it, and reloads the registry.

Install is refused when:

  • the manifest is missing
  • the manifest is invalid JSON
  • the plugin id is invalid
  • the plugin id is already used
  • the category is unknown
  • a permission is unknown
  • no surfaces are declared
  • the entry path is absolute
  • the entry path escapes the plugin folder
  • the entry file does not exist
  • obvious credential, token, password, webhook, or local test-account files are

present

User plugins are installed under:


%APPDATA%/Shockless/plugins/<plugin-id>/

Portable user plugins can also be placed under:


dist/portable/Shockless/plugins/<plugin-id>/

The portable _premade-modules folder is intentionally ignored by automatic

discovery. Install one of those examples with Plugin Manager -> Install From

Folder when you want to run it.

Folder Layout

Minimum folder:


my-plugin/
  shockless.plugin.json
  plugin.js

Recommended folder:


my-plugin/
  shockless.plugin.json
  plugin.js
  README.txt
  assets/

Do not store account lists, passwords, tokens, webhook URLs, or test

credentials in a plugin folder. The installer rejects obvious private file

names and the package verifier checks source/package-facing files for private

local artifacts.

Packet-facing plugins should prefer named packet helpers and documented event

payloads over numeric header literals. If a header is not known yet, keep that

fact visible in plugin output instead of hardcoding a guessed id; header ids can

move between client builds.

Manifest

Every plugin must include shockless.plugin.json:


{
  "id": "sample-plugin",
  "name": "Sample Plugin",
  "version": "1.0.0",
  "author": "local",
  "description": "A local Shockless plugin.",
  "entry": "plugin.js",
  "icon": "terminal",
  "category": "developer",
  "permissions": ["ui.panel", "packet.read"],
  "managedRuntime": {
    "clientRights": []
  },
  "surfaces": [
    {
      "id": "panel",
      "kind": "panel",
      "label": "Sample Panel",
      "enabledByDefault": true,
      "summary": "Starter panel for a local plugin."
    }
  ],
  "commands": [],
  "hotkeys": []
}

Required fields:

  • id: stable plugin id.
  • name: user-visible name.
  • version: plugin version string.
  • entry: relative path to the entry JavaScript file.
  • category: user plugin category.
  • permissions: permission list.
  • surfaces: at least one UI surface. Surfaces may include a layout array; if omitted, Shockless generates a metadata-based schema layout so the plugin still has a reproducible panel.

Optional fields:

  • author: author name.
  • description: short panel/manager summary.
  • icon: icon key from the app icon map.
  • managedRuntime.clientRights: source Session rights the plugin owns and

should clean up when disabled. This requires the client.rights permission.

  • commands: reserved command declarations.
  • hotkeys: reserved hotkey declarations.

Categories

Allowed user categories:

  • session: multi-client/session tools.
  • room: room data, room actions, navigation, overlays.
  • user: avatar/user tools.
  • inventory: hand/items/inventory helpers.
  • automation: controlled repeated workflows.
  • social: friends, messages, requests, visitor-related tools.
  • developer: diagnostics, packet tooling, experimental local tools.

Reserved category:

  • core: built-in recovery tabs only.

Icons

Use one of the existing icon keys. Unknown keys fall back to an alert icon.

These keys map to the app's bundled Lucide icon set and are the only stable

manifest values today:

  • activity: performance, status, or live tracking.
  • bot: automation, session, or repeated workflow helpers.
  • command: commands, mapped actions, or quick tools.
  • hammer: builder, wall, furniture, or room editing.
  • info: facts, lookups, account data, and summaries.
  • list: logs, packet tables, history, and ordered data.
  • map: rooms, Navigator, location, and route tools.
  • messages: chat, social, private messages, and requests.
  • package: inventory, hand, item bundles, or collected objects.
  • plug: connection, profile import, lifecycle, and relay status.
  • sofa: room furniture and room object tools.
  • terminal: console, packet, diagnostics, and development tools.
  • user: avatar/user profile and user action tools.
  • wrench: settings, utilities, and repair tools.

Surfaces

Each plugin needs at least one surface:


{
  "id": "panel",
  "kind": "panel",
  "label": "Sample Panel",
  "enabledByDefault": true,
  "summary": "Starter panel for a local plugin."
}

Fields:

  • id: surface id inside the plugin.
  • kind: panel, overlay, status, or commands.
  • label: user-visible surface name.
  • enabledByDefault: whether the surface starts enabled.
  • summary: short user-visible explanation.

Current behavior:

  • Plugin enabled state controls whether the plugin runs and appears in the app

rail. Disabling a plugin stops its Worker, blocks normal runtime API calls,

and keeps only storage/declared cleanup routes available during disposal.

  • panel: controls whether the host-rendered panel surface is mounted in the

Plugin Manager detail page.

  • overlay: controls overlay-style surfaces. Built-in overlays such as Room

Info and Social private-message bulletins are gated by their own overlay

surfaces; user plugin overlays are persisted for schema/runtime use.

  • status: controls status-style surfaces. Built-in status surfaces such as the

FPS overlay are gated by this setting; user plugin status surfaces are

persisted for schema/runtime use.

  • commands: controls whether the Plugin Manager shows that plugin's

command/hotkey metadata tab. Runtime plugin command registration remains a

reserved host phase.

The Plugin Manager can toggle each surface independently.

Schema UI

Plugin UI is declarative. Plugin code does not register React components or

write HTML into the app. The manager renders JSON schema elements from

ui.preview, ui.settings, and surfaces[].layout with the same controls used

by built-in plugins. This keeps third-party plugin UI consistent and makes a

plugin reproducible from its manifest plus plugin.js.

Supported schema element types:

  • header: section heading.
  • text: plain explanatory text.
  • divider: horizontal separator.
  • section: nested group of schema elements.
  • notice: themed message block.
  • button: action or command trigger.
  • buttonGrid: responsive group of action buttons with one to six columns.
  • toggle / checkbox: boolean setting.
  • textInput, numberInput, slider, colorInput, select, keybind:

editable controls.

  • table: structured rows. Optional rowKey, selectedRowKey, and

rowAction make rows selectable and emit ui.onAction() events with the

selected row key. Optional maxRows limits rendered rows without truncating

the source data in plugin code.

  • kv: key/value facts.
  • log: fixed log-style rows.

Every action element may declare action; buttons may also declare command.

The runtime host delivers control changes through api.ui.onAction() and the

renderer routes built-in action strings through the app command handlers.

Schema control values are persisted by plugin id and element id in local app

storage so plugin settings survive app restarts. Plugins that need structured

or long-lived data should still use the plugin-scoped api.storage calls.

Runtime schema helpers:

  • api.ui.registerSurface({ id, layout }): creates or replaces a host-rendered schema surface for this plugin. registerPanel() is a compatibility alias for the same schema path, not a React panel hook.
  • api.ui.updateSurface(surfaceId, layout): replaces the layout for a declared surface such as panel.
  • api.ui.setValue(key, value): updates the current value shown by a schema control.
  • api.ui.onAction(handler): subscribes to button, button-grid, table row,

toggle, checkbox, input, select, and keybind events emitted by the schema

renderer.

Plugin UI must stay declarative JSON schema. Arbitrary HTML, DOM access, and React component mounting are intentionally blocked.

Example selectable table plus action grid:


{
  "type": "table",
  "label": "Room Items",
  "columns": [
    { "key": "name", "label": "Item" },
    { "key": "id", "label": "ID" }
  ],
  "rows": [
    { "key": "floor:123", "name": "Chair", "id": 123 }
  ],
  "rowKey": "key",
  "selectedRowKey": "floor:123",
  "rowAction": "items.select",
  "maxRows": 80
}

{
  "type": "buttonGrid",
  "columns": 3,
  "buttons": [
    { "label": "Use", "action": "items.use", "variant": "primary" },
    { "label": "Rotate", "action": "items.rotate" },
    { "label": "Pickup", "action": "items.pickup", "variant": "danger" }
  ]
}

Permissions

Only request what the plugin actually needs.

  • ui.panel: plugin declares a panel surface.
  • ui.status: plugin declares a status surface.
  • ui.overlay: plugin declares an overlay surface.
  • console.commands: plugin intends to register console commands.
  • engine.snapshot: plugin needs selected-client runtime snapshots.
  • engine.control: plugin intends to control engine/runtime behavior.
  • notifications.show: plugin can render selected-client native top-right

bulletin notifications through the Shockless notification manager.

  • client.rights: plugin reads or mutates selected-client source Session

rights such as fuse_habbo_chooser and fuse_furni_chooser.

  • events.room: plugin listens to room lifecycle/user events.
  • events.chat: plugin listens to chat events.
  • events.packet: plugin listens to packet events without packet mutation.
  • events.session: plugin listens to client/session events.
  • chat.send: plugin sends normal room chat. chat.say, chat.shout, and chat.whisper are readable mode helpers over the same permission.
  • actions.avatar: plugin sends validated avatar movement/action packets such

as walk, wave, dance, carry drink, and apply look.

  • actions.social: plugin sends validated social packets such as friend

request, private message, accept/decline request, remove, follow, and request

refresh.

  • actions.fishing: plugin sends validated Fishing start, minigame input,

derby register, token, products, rod, stats, and Fishopedia packets. Use it

with actions.avatar when the plugin also needs generic movement such as

avatar.walkToArea().

  • actions.furni: plugin sends validated floor/wall furniture move, rotate,

use, and pickup packets through furni.*.

  • actions.plants: plugin sends validated Gardening plant move, water,

harvest, and compost packets. Pair it with engine.snapshot for

plants.findPlants(), plants.getState(), plants.planCycle(), and

plants.runCycle().

  • actions.wallItems: plugin sends validated wall item move and pickup

packets.

  • storage: plugin needs plugin-scoped persistence.
  • packet.read: plugin needs non-sensitive decrypted packet metadata.
  • packet.inject: plugin intends to send scoped packets.
  • packet.intercept: plugin intends to intercept non-sensitive decrypted

packets.

  • packet.intercept.sensitive: plugin explicitly needs sensitive packet

families.

Sensitive access is never implied by packet.read or packet.intercept.

Sensitive client headers currently include login/key identity families such as

TRY_LOGIN [4], UNIQUEID [6], and GENERATEKEY [202].

The client.*Rights APIs mutate the selected Shockless source

Session.user_rights list for advanced clientside runtime experiments. They do

not grant server account privileges and no bundled public addon enables them by

default.

If a plugin grants source Session rights, list them in

managedRuntime.clientRights. Disabled plugins are blocked from normal runtime

actions, but their dispose handler may still remove only those declared rights.

Entry File

The template entry uses an ESM-style module:


export function activate(api) {
  const { log, packets, subscriptions } = api;
  const cleanup = subscriptions.create();

  log.info("Sample plugin activated.");
  cleanup.add(packets.on("server", { header: 24 }, (packet) => {
    log.info(`server packet ${packet.header}`);
    return packet.allow();
  }));

  return cleanup.dispose;
}

Current status:

  • The entry path is validated and displayed.
  • The entry file is packaged/copied with the plugin.
  • Enabled user plugin entries are loaded in a restricted Worker host.
  • The host enforces manifest permissions before delivering events or running

privileged APIs.

Preferred plugin code destructures the API groups it uses:


export async function activate(api) {
  const { avatar, log, room, storage, subscriptions } = api;
  const cleanup = subscriptions.create();

  await storage.remember("lastActivated", { active: true });
  cleanup.add(room.onReady((event) => log.info(`Room ready: ${event.room?.name ?? "unknown"}`)));

  await avatar.wave();
  return cleanup.dispose;
}

The complete source-backed API list lives in

plugin-api.html. Keep examples in this

guide on the clean group style: avatar.walkToItem(), furni.moveFloorItem(), chat.say(), packets.on(), and so on.

The architecture direction behind this style is documented in

xabbo-style-plugin-rewrite-plan.md and

plugin architecture notes.

Origins/Shockwave packet builders and runtime managers.

Common events are available through raw events.on(...) and, where listed, through readable helper groups such as room.onReady(...), runtime.onSnapshot(...), session.onSelected(...), and chat.onMessage(...).

The host also provides small script helpers so plugins do not copy parser

boilerplate into every file:

  • storage.remember(key, value): stores { value, updatedAt }.
  • room.itemKey(item), room.userKey(user), and room.objectId(item):

stable ids for live maps and action routing.

  • room.summarizeItem(item), room.summarizeItems(items), and

room.countItems(event): compact data for UI/storage.

  • room.keepSelectedItem(selected, items) and

room.keepSelectedWallItem(selected, items): preserve selected items across

room updates.

  • furni.wallMoveLocation(item, { deltaX, deltaY, orientation }): converts a

parsed wall item into the move shape accepted by furni.moveWallItem().

  • packets.summary(packet), packets.hasName(packet, ...names), and

packets.field(packet, labelOrRegex): readable packet checks without local

packet-summary helper functions.

Common events:

  • session.selected: selected client changed.
  • runtime.snapshot: selected visible runtime snapshot changed.
  • room.changed: selected visible client entered or changed room.
  • room.ready: selected visible room became ready.
  • room.users: full current room user list after a room change.
  • room.userJoined: a new room user appeared after the current room snapshot.
  • room.userLeft: a room user disappeared after the current room snapshot.
  • room.items: full parsed floor and wall item list after a room load or item

change.

  • room.floorItemsLoaded / room.wallItemsLoaded: floor/wall item lists after

a room load.

  • room.itemAdded / room.itemUpdated / room.itemRemoved: item deltas for

all room object kinds, with floor/wall-specific variants.

  • chat.message: a new selected-room chat line appeared.
  • packet: new decoded packet row from any direction.
  • packet.client: new client-to-server decoded packet row.
  • packet.server: new server-to-client decoded packet row.

Packet event filtering:


const { log, packets } = api;
const off = packets.on("server", { header: 24 }, (packet) => {
  log.info(JSON.stringify(packets.summary(packet)));
  return packet.allow();
});

Supported directions are server, client, and all. Filters can include

header or packetName. Packet handler return values are accepted for API

compatibility, but packet mutation is not applied until validated mutation is

enabled.

Raw sends are available through packets.send(clientId, packet) when the

manifest includes packet.inject. Prefer typed APIs for known actions, but use

the raw builder when you are testing a valid client packet family that does not

have a domain helper yet:


export async function activate({ log, packets, session }) {
  const clients = await session.getClients();
  const clientId = clients.selectedClientId;

  await packets.send(clientId, { packetName: "WAVE" });
  await packets.send(clientId, { header: 52, bodyEscapedText: "hello[0]" });
  log.info("Sent two validated client packets.");
}

The builder accepts one body source per packet: bodyBytes, bodyHex,

bodyText, bodyEscapedText, or full packetText. packetName must resolve

through the outgoing packet table. Unknown numeric headers are allowed and

display as UNKNOWN_HEADER; sensitive login/key headers such as

TRY_LOGIN [4], UNIQUEID [6], and GENERATEKEY [202] are refused.

Plugins can schedule the same validated API without private host access. Keep

the timer owned by the plugin cleanup scope so disabling or unloading the

plugin stops future sends:


export async function activate({ packets, session, subscriptions }) {
  const cleanup = subscriptions.create();
  const timer = setInterval(async () => {
    const clients = await session.getClients();
    await packets.send(clients.selectedClientId, { packetName: "WAVE" });
  }, 60_000);
  cleanup.add(() => clearInterval(timer));
  return cleanup.dispose;
}

The built-in Injection panel uses this same validated builder. Its panel is

schema-rendered and contains only packet direction, complete packet text,

validation, session/repeat targeting, saved packets, and sent history. Source

window IDs, element IDs, stage clicks, and other runtime commands belong to

their domain APIs rather than packet injection.

Action Examples

Walk to a tile, parsed floor item, parsed area, or live room user:


export async function activate(api) {
  const { avatar } = api;
  await avatar.walkTo(6, 8, 0, { clientId: 1 });
  await avatar.walkToItem("pumpkin", { clientId: 1 });
  await avatar.walkToItem({ objectId: 12345 }, { clientId: 1 });
  await avatar.walkToArea({ className: "ads_fish_area", exact: true }, { clientId: 1 });
  await avatar.walkToArea({ x: 10, y: 12 }, { clientId: 1 });
  await avatar.walkToUser("dek", { offset: { x: 1, y: 0 }, clientId: 1 });
}

walkToItem() and object-selector walkToArea() resolve only floor/passive

objects with tile coordinates. walkToUser() resolves the live room user list by

name, account id, room index, or row id. Use furni.* for moving, rotating, or

picking up furniture, and teleport.enter() for the common walk-and-use

teleport flow.

Find, move, rotate, use, or pick up furniture with selectors:


export async function activate(api) {
  const { furni, log, room, subscriptions } = api;
  const cleanup = subscriptions.create();

  cleanup.add(room.onItems(async (event) => {
    const pumpkins = event.floorItems.filter((item) => String(item.searchText || item.name || item.className).includes("pumpkin"));
    log.info(`pumpkins in room: ${pumpkins.length}`);
  }));

  const matching = await furni.findItems({ query: "chair", kind: "floor" });
  for (const item of matching) {
    await furni.rotateFloorItem(item, 4);
  }

  return cleanup.dispose;
}

For pickup scripts, pass a room item payload or include kind so the host knows

whether to send the floor or wall pickup route:


const posters = await furni.findItems({ query: "poster", kind: "wall" });
for (const poster of posters) await furni.pickupItem(poster);

const poster = await furni.findItem({ query: "poster", kind: "wall" });
const nextLocation = furni.wallMoveLocation(poster, { deltaX: 1 });
if (nextLocation) await furni.moveWallItem(nextLocation, nextLocation);

Use a floor item by id or selector:


export async function activate({ avatar, furni }) {
  const item = await furni.findItem({ query: "switch", kind: "floor" });
  if (!item?.tile) return;

  await avatar.walkTo(item.tile.x, item.tile.y, Number(item.objectId));
  await furni.useFloorItem(item, "0");
}

furni.useFloorItem() sends SET_STUFF_DATA [74] with the selected object id

and value as outgoing Shockwave strings. Use it for floor furniture that accepts

state through the standard item-use route.

Allow native floor item drag/drop outside visible room tiles:


export async function activate({ furni }) {
  await furni.setAnywherePlacementEnabled(true);
  return () => furni.setAnywherePlacementEnabled(false);
}

furni.setAnywherePlacementEnabled() requires actions.furni and

engine.control. It keeps the normal Object Mover cursor and click flow in

charge; it only changes the floor-coordinate validation while the plugin is

enabled. When that source mover commits a synthetic off-room coordinate,

Shockless sends the same ORIGINS_SET_FURNI_LOCATION [1257] packet used by

Origins' precise mover instead of the model-bounded MOVESTUFF [73] route.

Use the precise floor-location route directly when a plugin already knows the

object id and target coordinate:


export async function activate({ furni }) {
  const item = await furni.findItem({ query: "trophy", kind: "floor" });
  if (!item) return;

  await furni.setFloorItemLocation(item, 14, 11, 0);
}

furni.setFloorItemLocation() requires actions.furni. Selectors still need

engine.snapshot; numeric ids with explicit x/y/height can be sent without a

snapshot.

Leave a room when a matching user or badge appears:


export function activate({ room, rooms, subscriptions }) {
  const cleanup = subscriptions.create();

  cleanup.add(room.onUserJoined(async ({ user, clientId }) => {
    if (user.name === "SomeUser" || user.badgeCode === "BADGE_CODE") {
      await rooms.leave({ clientId });
    }
  }));

  return cleanup.dispose;
}

Use a teleport item without writing packet code:


export async function activate({ teleport }) {
  await teleport.enter({ query: "teleport", kind: "floor" });
}

Allow native wall item drag/drop outside visible wall bounds:


export async function activate({ wallItems }) {
  await wallItems.setAnywherePlacementEnabled(true);
  return () => wallItems.setAnywherePlacementEnabled(false);
}

Move a wall item with parsed wall/local coordinates:


await wallItems.moveItem({
  itemId: 12345,
  wallX: 3,
  wallY: 1,
  localX: 24,
  localY: 12,
  orientation: "l",
  className: "poster",
});

Move a wall item to a point the user clicks on the stage:


export async function activate({ engine, room, stage, wallItems }) {
  const snapshot = await engine.getSnapshot();
  const item = room.wallItemsFromSnapshot(snapshot)[0];
  if (!item) return;

  const point = await stage.captureNextClick({ timeoutMs: 15000 });
  await wallItems.moveAnywhere(item, point);
}

Hide selected users locally while the plugin is active:


export async function activate({ filters }) {
  await filters.setHiddenUsers(["SomeUser", "233421"]);
  return () => filters.clearHiddenUsers();
}

Define plugin-local events from packet data when the host does not expose a

named event yet:


export function activate(api) {
  const { events, log } = api;
  const offPacket = events.defineFromPacket(
    "custom.usersPacket",
    "server",
    { packetName: "USERS" },
    (packet) => ({ count: packet.decodedFields.length, packet }),
  );

  const offEvent = events.on("custom.usersPacket", ({ count }) => {
    log.info(`USERS packet decoded fields: ${count}`);
  });

  return () => {
    offPacket();
    offEvent();
  };
}

Packet Hooks

Packet-hook policy is generated from enabled plugin permissions and passed to

each embedded relay before the client starts.

Relay evaluation point:

  • client to server: after browser-side plaintext is parsed, before writing to

the official TCP socket

  • server to client: after official plaintext is parsed, before writing to the

browser websocket

Policy decisions include:

  • whether the packet is sensitive
  • which enabled plugins can read it
  • which enabled plugins can intercept it
  • which enabled plugins can inject scoped packets

Current behavior:

  • The relay policy is generated from enabled plugin permissions and is passed

to each relay process.

  • The renderer host emits decoded packet rows as packet, packet.client,

and packet.server events to plugins with packet/event permissions.

  • Packet handler return values such as allow, block, replace, and

inject are accepted by the worker API for forward compatibility.

  • Packet mutation is not applied yet. Raw packet send/mutation will only be

enabled through a validated Shockwave packet builder so plugins cannot send

malformed packets.

This follows the same rule as mature proxy extension systems: broad hooks are

useful, but packet writes need type/direction validation before they touch a

live session.

Commands And Hotkeys

commands and hotkeys are manifest fields reserved for plugin command

registration. They are validated as arrays but not executed yet. Plugin code

can call console.registerCommand(), but the host returns a reserved feature

result until the command registry phase is implemented.

Recommended future shape:


{
  "commands": [
    {
      "name": "sample",
      "label": "Sample",
      "description": "Run sample command."
    }
  ],
  "hotkeys": [
    {
      "key": "F8",
      "command": "sample"
    }
  ]
}

For built-in command bindings today, use the Settings tab or the backtick

console bind command.

Command name values are machine ids and must be lowercase identifier-style

strings such as sample.ping. Use optional label for display text in Plugin

Manager.

Plugin Manager UI

Plugin Manager shows:

  • user plugin root
  • portable plugin root
  • total plugin count
  • enabled plugin count
  • create-from-template controls
  • install/reload/open-folder actions
  • every built-in and user plugin
  • pinned/enabled/disabled state
  • permissions
  • surfaces and surface toggles
  • load errors

Pinned plugins cannot be disabled. Currently pinned core plugins are Plugin

Manager and Settings. Connection starts enabled by default but remains an

optional module.

User Plugin Panel

Every enabled user plugin gets a standard panel showing:

  • status
  • version
  • author
  • category
  • entry file
  • surface count
  • permissions
  • surface enabled state
  • open-folder action
  • reload action

This gives users immediate feedback that a created or installed plugin is

registered correctly even before custom panel rendering is available.

Packaging

Portable builds include:


resources/app/dist/plugins/template/shockless.plugin.json
resources/app/dist/plugins/template/plugin.js
resources/app/dist/plugins/template/README.txt
plugins/_premade-modules/README.txt
plugins/_premade-modules/welcome-message/shockless.plugin.json
plugins/_premade-modules/welcome-message/plugin.js
plugins/_premade-modules/welcome-message/README.txt
plugins/_premade-modules/<module-id>/shockless.plugin.json
plugins/_premade-modules/<module-id>/plugin.js
plugins/_premade-modules/<module-id>/README.txt

The portable verifier fails if the template files, premade Welcome Message

plugin, or premade module source pack are missing. Premade source-reference

plugins live in Shockless/plugins/_premade-modules/ and can be installed

through Plugin Manager when users want to copy or customize them.

Portable users can install plugins through the manager or place plugin folders

under:


Shockless/plugins/<plugin-id>/

Validation Checklist

When developing plugin-manager changes:


npm run typecheck -- --pretty false
npm test
npm run build

Relevant tests:

  • tests/pluginManager.test.ts
  • tests/pluginRelayHooks.test.ts
  • tests/contracts.test.ts
  • tests/securityArtifacts.test.ts

Useful manual checks:

  • Create a plugin from template.
  • Reload plugins.
  • Disable the created plugin and confirm it disappears from the rail.
  • Re-enable it from Plugin Manager and confirm the user plugin panel opens.
  • Install a copied plugin folder and confirm duplicate ids are rejected.
  • Try installing a folder with an obvious private file name and confirm it is

refused.

Example Plugins

Working welcome message example:


examples/premade-plugins/welcome-message/
  shockless.plugin.json
  plugin.js
  README.txt

What it demonstrates:

  • manifest permissions for events.room, chat.send, storage, and

engine.snapshot

  • events.on("room.userJoined", handler)
  • ignoring the selected user through the event.user.isSelf flag
  • a per-room/user cooldown using plugin-local memory
  • room chat sending through chat.say(message, { clientId }), chat.shout(), or chat.whisper()

Install it with Plugin Manager -> Install From Folder, select

examples/premade-plugins/welcome-message, then enter a private room and let another

user join.

Read-only packet watcher manifest:


{
  "id": "packet-watcher",
  "name": "Packet Watcher",
  "version": "1.0.0",
  "entry": "plugin.js",
  "category": "developer",
  "permissions": ["ui.panel", "packet.read"],
  "surfaces": [
    {
      "id": "panel",
      "kind": "panel",
      "label": "Packet Watcher",
      "enabledByDefault": true,
      "summary": "Displays packet watcher status."
    }
  ]
}

Room overlay manifest:


{
  "id": "room-overlay-tools",
  "name": "Room Overlay Tools",
  "version": "1.0.0",
  "entry": "plugin.js",
  "category": "room",
  "permissions": ["ui.panel", "ui.overlay", "engine.snapshot"],
  "surfaces": [
    {
      "id": "panel",
      "kind": "panel",
      "label": "Room Overlay Tools",
      "enabledByDefault": true,
      "summary": "Room overlay controls."
    },
    {
      "id": "overlay",
      "kind": "overlay",
      "label": "Room Overlay",
      "enabledByDefault": false,
      "summary": "Optional room overlay surface."
    }
  ]
}

Room action manifest:


{
  "id": "room-action-tools",
  "name": "Room Action Tools",
  "version": "1.0.0",
  "entry": "plugin.js",
  "category": "automation",
  "permissions": ["ui.panel", "engine.snapshot", "actions.avatar", "actions.furni"],
  "surfaces": [
    {
      "id": "panel",
      "kind": "panel",
      "label": "Room Actions",
      "enabledByDefault": true,
      "summary": "Schema controls for selected room actions."
    }
  ]
}