Documentation
ModdingControl Protocol

WebSocket Protocol

Connect and interact with the WebSocket server

The WebSocket transport uses a JSON event envelope: { "event": "...", "args": [...] }. Connect to ws://localhost:8000, then send a hello handshake as your first message. Any message before hello closes the socket.

Handshake

The client sends a hello event. The id field is required and acts as the unique client identifier. The version must match the server's protocol version (1).

New client (no existing entry):

// Client → Server
{
  "event": "hello",
  "args": [{
    "id": "my-tool",
    "name": "My Tool",
    "description": "External control client for my-tool",
    "version": 1,
    "permissions": ["config:read", "hierarchy:read"]
  }]
}

// Server → Client (token included for first-time clients)
{
  "event": "hello",
  "args": [{
    "ok": true,
    "protocol_version": 1,
    "token": "abc123..."
  }]
}

Returning client (has a RegisteredEntry from a previous session):

// Client → Server (must include the saved token)
{
  "event": "hello",
  "args": [{
    "id": "my-tool",
    "version": 1,
    "token": "abc123..."
  }]
}

// Server → Client (no token — already known)
{
  "event": "hello",
  "args": [{ "ok": true, "protocol_version": 1 }]
}

Rejection — the socket is closed after this message:

// Server → Client
{
  "event": "hello:reject",
  "args": [{
    "reason": "Invalid or missing token for existing client.",
    "protocol_version": 1
  }]
}
FieldRequiredTypeDescription
idYesstringUnique client identifier
versionYesintProtocol version — must be 1
nameNostring | objectDisplay name ("My Tool" or {"en-US":"My Tool"})
descriptionNostring | objectDescription (string or localized object)
tokenNo*stringMandatory for returning clients with existing RegisteredEntry
permissionsNostring[]Permissions this client may request later

name and description support localization via {"en-US": "...", "fr-FR": "..."}. If omitted, a fallback is generated from the endpoint address.

Requesting Permissions

After handshake, request the permissions you declared. Only permissions that were in the hello's permissions array or already granted are accepted. Newly declared permissions are auto-granted and fire a PermissionEvents.OnPermissionRequest event on the server.

// Client → Server
{ "event": "permission:request", "args": [["config:read", "hierarchy:read"]] }

// Server → Client
{
  "event": "permission:response",
  "args": [{
    "allowed": ["config:read"],
    "rejected": [
      { "id": "hierarchy:read", "reason": "Not declared at hello time." }
    ]
  }]
}

Listing Permissions

// Client → Server
{ "event": "permission:list", "args": [] }

// Server → Client
{
  "event": "permission:list",
  "args": [{
    "allowed": ["config:read"],
    "pending": ["hierarchy:write"],
    "rejected": [{ "id": "logger:read", "reason": "Denied." }]
  }]
}
  • allowed — PermissionState.Granted, usable immediately
  • pending — PermissionState.Declared, declared but not yet granted/denied
  • rejected — PermissionState.Denied, explicitly denied (e.g. via admin)

Calling Operators

Any registered IOperator can be called by using its name as the event. Arguments are passed in args[0].

// Client → Server
{ "event": "config_get", "args": [{ "key": "settings.control.port" }] }

// Server → Client
{ "event": "config_get", "args": [{ "ok": true, "value": 8000 }] }

If you lack a required permission, the server blocks the call and responds:

// Server → Client
{
  "event": "permission:required",
  "args": [{
    "event": "config_set",
    "require": ["config:write"]
  }]
}

Full JavaScript Example

const ws = new WebSocket("ws://localhost:8000");
let token = localStorage.getItem("nox_token");

ws.onopen = () => {
    ws.send(JSON.stringify({
        event: "hello",
        args: [{
            id: "my-tool",
            name: "My Tool",
            description: "Custom control client",
            version: 1,
            token: token || undefined,
            permissions: ["config:read", "mods:read"]
        }]
    }));
};

ws.onmessage = (msg) => {
    const { event, args } = JSON.parse(msg.data);
    const payload = args?.[0] ?? {};

    if (event === "hello") {
        // New client: store the issued token
        if (payload.token)
            localStorage.setItem("nox_token", payload.token);
        // Request the permissions we declared
        ws.send(JSON.stringify({
            event: "permission:request",
            args: [["config:read"]]
        }));
    }
    else if (event === "hello:reject") {
        console.error("Handshake rejected:", payload.reason);
        ws.close();
    }
    else if (event === "permission:response") {
        console.log("Allowed:", payload.allowed,
                    "Rejected:", payload.rejected);
        // Now call an operator
        ws.send(JSON.stringify({ event: "mods_list", args: [{}] }));
    }
    else if (event === "permission:required") {
        console.log("Need:", payload.require, "for", payload.event);
    }
    else {
        console.log(event, payload);
    }
};

ws.onclose = () => console.log("Disconnected");

Events Reference

EventDirectionArgs shapeDescription
helloClient → Server[{ id, version, name?, description?, token?, permissions? }]Handshake — must be the first message
helloServer → Client[{ ok: true, protocol_version, token? }]Handshake ack — token only for new clients
hello:rejectServer → Client[{ reason, protocol_version }]Handshake rejected, then socket closes
permission:requestClient → Server[["p1", "p2"]]Request specific permissions (declared at hello)
permission:responseServer → Client[{ allowed: [...], rejected: [{id, reason}] }]Result of a permission request
permission:listClient → Server[]Query current permission state
permission:listServer → Client[{ allowed, pending, rejected }]Full permission state for this client
permission:requiredServer → Client[{ event, require }]Missing permission — call blocked
{operator}Client → Server[{ ...args }]Call a registered operator by name
{operator}Server → Client[{ ok, value }] or [{ ok, error }]Operator result

On this page