Documentation

Quick start

Run the proxy, sign in, point your client at it and inspect every packet in real time.

Install & run

ProxyMod is a desktop application that runs on your own machine. It needs Java 21 or newer. Grab the latest proxymod.jar from your dashboard, then launch it:

terminalbash
# Open the launcher UI
java -jar proxymod.jar

# …or start a session straight away, skipping the UI:
java -jar proxymod.jar <host> <port> <version>
# e.g. java -jar proxymod.jar play.example.net 25565 1.20.1

With no arguments, ProxyMod opens a small control panel in your browser at http://localhost:8080 (if it doesn't open automatically, visit that address by hand). Passing host, port and version launches a session directly — the version must be one of the supported versions.

Sign in

ProxyMod needs two sign-ins, both handled in your browser:

  • Your ProxyMod account — on first launch (and whenever your license can't be confirmed) a page opens on proxymod.net. Sign in there; once an active license is verified, the app unlocks and the control panel opens.
  • Your Minecraft account(s) — ProxyMod keeps an account store and makes sure at least one Minecraft account is signed in before a session starts. The first time, it runs Microsoft's device-code flow: a verification page opens, log in and approve the request. Added accounts are remembered for next time.

Both tokens are cached under your app-data folder, so you only sign in again once they expire (or your license lapses).

Where things live: Windows %APPDATA%\Proxymod · macOS ~/Library/Application Support/Proxymod · Linux ~/.local/share/Proxymod.

Connect your client

In the control panel, enter the host and port of the server you want to analyse, pick the Minecraft version to speak, and start the session. The version you choose selects the protocol implementation ProxyMod uses, so it must match the target server — only supported versions are offered. ProxyMod then listens locally on 127.0.0.1:25565 and relays traffic to that target.

Now open Minecraft, add a server pointing at your proxy, and join it:

Minecraft · Add Serveraddress
Server Address: 127.0.0.1

Every packet now flows client → ProxyMod → target server and back, decoded as it passes through. The connection is authenticated and encrypted, exactly like a direct join.

Note: ProxyMod is a debugging tool. Only analyse connections and servers you own or are authorised to test.

Managing scripts

Once a session is running, the panel at http://localhost:8080 becomes the Script Manager. From there you can install plugins from the marketplace, then load, unload or delete them on the fly — no restart required.

  • Install — downloads the plugin from ProxyMod and verifies its SHA-256 hash before installing it. Only scripts hosted on proxymod.net can be installed, and they're capped at 256 KB.
  • Load / Unload — starts or stops a script against the live session.
  • Delete — unloads the script (if running) and removes it.

Prefer to write your own? Build a .lua plugin and submit it from your dashboard — once it's approved you install it from the marketplace like any other plugin. You can also attach page assets (.html/.js/.css) to give your plugin its own dedicated page.

Lua API

A plugin is a single Lua file. Define any of the lifecycle callbacks below and ProxyMod calls them as events happen:

  • onLoad() — called once when the script is loaded or reloaded.
  • onUnload() — called when the script is stopped.
  • onTick() — called every tick (50 ms, i.e. 20×/second).
  • onPacket(event) — called for every packet, in both directions.
  • onWebRequest(request) — called when the plugin's own HTTP endpoint receives a request.

The global proxy table is your toolbox:

  • proxy.time() — ticks elapsed since the script loaded.
  • proxy.log(value) — print to the proxy console; tables are pretty-printed.
  • proxy.schedule(delayMs, fn) — run fn once after a delay.
  • proxy.sendToClient(packet) — inject a packet toward the client.
  • proxy.sendToServer(packet) — inject a packet toward the server.
heartbeat.lualua
function onLoad()
    proxy.log("heartbeat loaded")
end

function onTick()
    -- every 200 ticks (~10s)
    if proxy.time() % 200 == 0 then
        proxy.log("still alive at tick " .. proxy.time())
    end
end

Reading & sending packets

Each onPacket event is a table:

  • event.type — the packet's name, e.g. "ClientboundSystemChatPacket".
  • event.destination — "client" (server → client) or "server" (client → server).
  • event.packet — the decoded packet as a Lua table; read its fields directly.
  • event.cancelled — set it to true to drop the packet before it's forwarded.
chat-filter.lualua
function onPacket(event)
    -- Log every system chat message from the server
    if event.type == "ClientboundSystemChatPacket" then
        proxy.log(event.packet)
    end

    -- Drop outgoing chat that mentions "spoiler"
    if event.destination == "server"
        and event.type == "ServerboundChatPacket" then
        local msg = event.packet.message or ""
        if string.find(msg, "spoiler") then
            event.cancelled = true
        end
    end
end

To craft a brand-new packet, call the global constructor named after the packet class — its arguments mirror the underlying MCProtocolLib constructor — then hand it to proxy.sendToClient or proxy.sendToServer:

keepalive.lualua
function onPacket(event)
    -- Answer the server's keep-alive ourselves
    if event.type == "ClientboundKeepAlivePacket" then
        proxy.sendToServer(ServerboundKeepAlivePacket(event.packet.pingId))
    end
end
Hot reload: reloading a script from the Script Manager re-runs onUnload then onLoad, so you can iterate without dropping your Minecraft session.

Binary buffers

Some packets carry raw byte payloads, and crafting one by hand means writing the bytes yourself. The global ByteBuf module gives you a Netty-style buffer with separate reader and writer cursors, so you can build or parse binary data without juggling offsets:

  • ByteBuf.new(initialCapacity[, maxCapacity]) — a fresh, growable buffer. maxCapacity defaults to unbounded.
  • ByteBuf.wrap(data) — wrap an existing Lua string or byte[] userdata, ready to read.

Each buffer tracks a reader index and a writer index. write* methods append at the writer index and grow the buffer as needed; read* methods consume from the reader index; get*/set* work at an absolute index without moving either cursor. The write methods return the buffer, so calls chain. Helpers cover every width — Byte, Boolean, Short, Int, Long, Float, Double (each with a little-endian *LE variant), Minecraft's VarInt, plus readBytes/writeBytes and readString/writeString for UTF-8 text.

  • Cursors & sizing: readerIndex([i]), writerIndex([i]), setIndex(r, w), capacity(), readableBytes(), writableBytes(), isReadable([n]), isWritable([n]), clear(), skipBytes(n), ensureWritable(n).
  • Marks: markReaderIndex() / resetReaderIndex(), markWriterIndex() / resetWriterIndex(), discardReadBytes().
  • Views & export: slice(index, len), copy(index, len), toStringUtf8([index, len]), arrayCopy().
varint.lualua
function onLoad()
    -- Build a buffer: a VarInt length, then a UTF-8 string
    local buf = ByteBuf.new(16)
    buf:writeVarInt(5):writeString("hello")

    -- Read it back from the start
    local len = buf:readVarInt()
    proxy.log(buf:readString(len))   -- prints "hello"
end

HTTP endpoint

Define onWebRequest and ProxyMod starts a small HTTP server bound to 127.0.0.1 for that plugin (the chosen port is printed to the proxy console when the script loads). This lets a plugin expose a tiny local API or dashboard — handy for driving it from a browser or another tool while a session runs.

The callback receives a request table and must return a response table:

  • request.method — the HTTP method, e.g. "GET" or "POST".
  • request.path — the request path, e.g. "/status".
  • request.query — the raw query string (without the ?), or "".
  • request.body — the request body as a string (capped at 256 KB).

Return a table with status (a number), contentType (a string) and body. The body is serialised as JSON, so a Lua table comes out as a JSON object or array and a string as a quoted JSON string.

status-api.lualua
local packets = 0

function onPacket(event)
    packets = packets + 1
end

function onWebRequest(request)
    if request.path == "/status" then
        return {
            status = 200,
            contentType = "application/json",
            body = { uptime = proxy.time(), packets = packets },
        }
    end

    return { status = 404, contentType = "text/plain", body = "not found" }
end
Local only: the endpoint listens on 127.0.0.1, so it's reachable only from your own machine. It starts when the plugin loads and stops when it's unloaded. Static page assets are served before onWebRequest is called, so the two work together.

Page assets

Beyond a dynamic HTTP endpoint, a plugin can ship static .html, .js and .css files that build one or more dedicated pages. Attach them when you submit a plugin — or add a new version — from your dashboard. They're stored and pinned by SHA-256 just like the script, and ProxyMod re-downloads them automatically whenever their code changes.

  • Assets are served by the same per-plugin server on 127.0.0.1. Incoming requests are matched against your files first; index.html is served at /.
  • Anything that isn't a known asset falls through to onWebRequest, so a static page and a dynamic API can live side by side (e.g. index.html fetching /api/status).
  • Only .html/.js/.css files are accepted — UTF-8 text, up to 1 MB each and 20 per version.

In the Script Manager, a loaded plugin shows an Open page button that opens its page in your browser.

index.htmlhtml
<!doctype html>
<title>My plugin</title>
<h1>Packets seen: <span id="n">…</span></h1>
<script>
  // Same origin as the page → call the Lua onWebRequest API
  fetch("/api/status").then(r => r.json())
    .then(d => n.textContent = d.packets);
</script>