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:
# 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).
%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:
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.
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)— runfnonce after a delay.proxy.sendToClient(packet)— inject a packet toward the client.proxy.sendToServer(packet)— inject a packet toward the server.
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 totrueto drop the packet before it's forwarded.
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:
function onPacket(event)
-- Answer the server's keep-alive ourselves
if event.type == "ClientboundKeepAlivePacket" then
proxy.sendToServer(ServerboundKeepAlivePacket(event.packet.pingId))
end
end
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.maxCapacitydefaults to unbounded.ByteBuf.wrap(data)— wrap an existing Lua string orbyte[]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().
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.
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
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.htmlis 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.htmlfetching/api/status). - Only
.html/.js/.cssfiles 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.
<!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>