Skip to content

API overview

Voxen ships a separate api module with no dependency on the plugin’s internals. Every type in it is stable; everything else is not.

  1. Add the repository and the API as a compileOnly dependency:

    build.gradle.kts
    repositories {
    maven("https://repo.vao.zone/releases")
    }
    dependencies {
    compileOnly("zone.vao:voxen-api:1.0")
    }

    The plugin jar works as a dependency too, but the API module is smaller and pulls in nothing else.

  2. Declare Voxen in your plugin descriptor:

    plugin.yml
    depend: [Voxen]

    Use softdepend instead if your plugin should still load without it.

// static facade
Collection<ChannelInfo> channels = VoxenApi.channels();
// or through Bukkit's ServicesManager
VoxenService voxen = getServer().getServicesManager().load(VoxenService.class);

The facade throws if Voxen is not loaded yet, so in a softdepend plugin either ask first or take the ServicesManager route, which returns null instead:

if (VoxenApi.isAvailable()) {
// safe from here
}

Methods that take a Player read that player’s permissions and state, so Voxen runs them on the thread that owns them: the main thread on Paper, that player’s own region on Folia. Calling one from anywhere else still works, but the call waits for the hop, so keep those out of tight loops and off the network thread.

Everything else — mutes, reports, network lookups, panel pages, the text filter — is safe from any thread. The report methods are the only ones that reach the database, and they hand back a CompletableFuture rather than blocking.

Collection<ChannelInfo> all = VoxenApi.channels();
ChannelInfo global = VoxenApi.channel("global");
ChannelInfo current = VoxenApi.activeChannel(player);

ChannelInfo is an immutable snapshot: id, displayName, type, enabled, readOnly, crossServer, radius, worlds.

VoxenApi.setActiveChannel(player, "auction"); // joins it and talks in it
VoxenApi.joinChannel(player, "trade"); // joins it, keeps talking where they were
VoxenApi.leaveChannel(player, "trade");
Collection<ChannelInfo> mine = VoxenApi.joinedChannels(player);

All three answer false when the channel is unknown or disabled, or when the player may not join it; leaveChannel also answers false when they were not in it. Leaving the channel a player was talking in drops them back to the default one.

setActiveChannel is the one players notice, so it fires ChannelSwitchEvent first and answers false when a listener cancels it. This is also how you make a channel you registered yourself usable: register it, then move the players who should be in it.

// as the player, through the whole pipeline: permissions, mutes, cooldown, filter
boolean sent = VoxenApi.sendChannelMessage(player, "global", "hello");
// raw component, skips the pipeline entirely
VoxenApi.broadcastToChannel("global", Component.text("Server restarting"));
VoxenApi.isMuted(uuid);
VoxenApi.isMuted(uuid, "global");
VoxenApi.isIgnoring(source, target);
VoxenApi.sendPrivateMessage(sender, target, "hi"); // PM toggle, ignores, tag permissions
String nick = VoxenApi.nickname(player);
VoxenApi.setNickname(player, "<gold>Steve"); // length limits apply, permissions do not
PartyInfo party = VoxenApi.party(uuid);

A private message needs both players online. Mail waits instead, so an addon can tell somebody their auction sold while they are offline or on another server. They read it with /mail and get the usual notification on their next login.

UUID shop = UUID.nameUUIDFromBytes("MyShop".getBytes(UTF_8));
VoxenApi.sendMail(shop, "MyShop", seller, "Your diamond pickaxe sold for 400g.")
.thenAccept(stored -> {
if (!stored) getLogger().info("mailbox full, or mail is switched off");
});

The sender is yours to pick, so mail can come from your plugin under its own name rather than from a player.

Method What it does
sendMail(from, fromName, to, message) leaves mail, false when the mailbox is full
mailbox(target) the whole mailbox, newest first
unreadMail(target) only what has not been opened
markMailRead(target) marks everything read, as opening /mail does
deleteMail(target, id) removes one piece by its MailInfo.id
clearMail(target) empties the mailbox, answers with how many went

Every one of these reaches the database, so they answer through a CompletableFuture.

This is the programmatic path, not the /mail command. The cooldown, the word filter, the mute check and allow-when-online are all skipped, and so is the recipient’s ignore list. If you are relaying one player to another and want ignores honoured, check VoxenApi.isIgnoring(to, from) first.

Two limits do still apply: mail.max-per-player from modules/mail.yml, so a full mailbox answers false, and the module switch itself, because mail nobody can open is not worth storing.

Voxen’s chat filter and tag renderer work on any text, not only on chat, so a sign, a book, an auction name or a shop title can be held to exactly the rules your server already configured.

FilterResult result = VoxenApi.filterWords(input);
if (result.isBlocked()) return;
String safe = result.getContent(); // censored parts already replaced
Component pretty = VoxenApi.render(player, safe);
Method What it does
filterWords(text) the word list and the blocked patterns from modules/moderation.yml
filterLinks(text) the link filter, obfuscated addresses included
isClean(text) true when neither has anything against it
render(player, text) MiniMessage and & codes, limited to the tags that player may use
stripTags(text) the readable text with every tag removed

FilterResult carries a verdict of CLEAN, BLOCKED or CENSORED and the content to use from there: unchanged when it is clean or blocked, and with the offending parts already replaced when it was censored.

VoxenApi.registerPlaceholder("level", p ->
Component.text(getLevel(p)));

<level> is now usable in any chat format. Return null to render nothing. Unregister on disable.

A placeholder puts the same thing in every copy of a line. A decorator runs once for each viewer, so it can show a different thing to each of them.

VoxenApi.registerChatDecorator("friends.marker", (sender, viewer, channel, id, message) -> {
if (!friends.areFriends(viewer, sender)) return null;
return Component.text("★ ", NamedTextColor.GOLD).append(message);
});

message is the line as that viewer would have seen it, mention highlighting included. Return the replacement, or null to leave it alone. Voxen already builds every line per viewer for its own report and moderation buttons, so hooking in here costs nothing extra.

Ids are [a-z0-9_.-] up to 64 characters; prefix yours with your plugin name. One decorator per id, and they run in registration order with each one receiving what the last returned. A decorator that throws is logged and skipped, and the ones after it still run.

Only messages sent on this server go through decorators. A line arriving from another server over the network has no local sender and is passed through untouched.

VoxenApi.registerChannel(ChannelRegistration.builder("auction", "<gold>Auction")
.format("<gold>[A] <player> <dark_gray>» <white><message>")
.recipients(sender -> Bukkit.getOnlinePlayers().stream()
.filter(this::wantsAuctions)
.toList())
.build());

The RecipientProvider is optional; without one the channel behaves like a normal global channel. registerRecipients does the same thing for a channel that already exists in channels/, which is how you bolt a custom audience onto the bundled ones.

Runtime channels are not written to disk and disappear on restart. Register them in onEnable, and put the players who belong in one there with setActiveChannel.

VoxenApi.mute(MuteRequest.builder(uuid, "Steve", "AutoMod")
.duration(1, TimeUnit.HOURS)
.reason("spam")
.build());
VoxenApi.unmute(uuid, null, "AutoMod");

A bare request mutes every channel, forever, with no reason given; .channel(id) narrows it and .duration(...) limits it. Mutes go through the same path as the mute commands: stored, sent to the other servers on the network, and PlayerMuteEvent fires first, so another plugin can still refuse or shorten it.

Method What it does
mute(request) mutes; false when the channel is unknown or a listener cancelled it
unmute(target, channelId, moderator) lifts one mute; false when there was none
unmuteAll(target, moderator) lifts every mute and returns how many went
isMuted(uuid) / isMuted(uuid, channelId) reads the current state, no database call
VoxenApi.reports(20).thenAccept(open -> {
for (ReportInfo report : open) {
getLogger().info(report.getTargetName() + " — " + report.getReason());
}
});
VoxenApi.updateReport(id, ReportInfo.Action.RESOLVE, "AutoMod");

Reports live in the database, so every one of these methods answers through a CompletableFuture instead of blocking the caller. Use thenAccept from the server thread, or join() when you are already on one of your own.

Method What it does
reports(limit) the queue: everything still OPEN or CLAIMED, newest first
reports(statuses, limit) the same for any set of statuses; an empty set means all
report(id) one report, or null when it is gone
updateReport(id, action, moderator) claim, resolve, dismiss or delete it

updateReport goes through the same path as the queue commands: the audit trail records the action under moderator, and ReportUpdateEvent fires first, so another plugin can still refuse it. It completes with false when the report is gone, when it already has that status, or when a listener cancelled the change.

if (VoxenApi.networkConnected()) {
String where = VoxenApi.serverOf("Steve"); // "survival", or null
for (NetworkPlayer player : VoxenApi.networkPlayers()) {
getLogger().info(player.getName() + " on " + player.getServer());
}
}
Method What it does
serverId() this server’s network id, from network.server-id
networkConnected() whether the broker is up right now
serverOf(name) which server a player is on, null when nobody answers to the name
networkPlayers() everyone on the network, this server included

networkPlayers() leaves vanished players out, the way a player list should. serverOf does not, so a message can still be routed to a hidden moderator.

Remote entries come from presence tracking, so modules/presence.yml has to be on. Without a network both methods still work and simply describe this server alone.

Voxen already keeps a signed, deduplicated link between your servers over Redis, NATS or RabbitMQ. Addons can put their own payloads on it instead of standing up a second one.

VoxenApi.registerNetworkListener("myshop.sales", (channel, payload, from) -> {
Sale sale = gson.fromJson(payload, Sale.class);
getServer().broadcast(Component.text(sale.item() + " sold on " + from));
});
VoxenApi.sendNetworkMessage("myshop.sales", gson.toJson(sale)); // every server
VoxenApi.sendNetworkMessage("myshop.sales", gson.toJson(sale), "lobby"); // just one

Pick a channel name prefixed with your plugin, so two addons cannot land on the same one. Names are [a-z0-9_.-] up to 64 characters, payloads are any text up to 64 KiB. One listener per channel; a second registration for the same name returns false and leaves the first one alone.

Method What it does
sendNetworkMessage(channel, payload) sends to every other server
sendNetworkMessage(channel, payload, server) sends to one network id
registerNetworkListener(channel, listener) starts listening, false if the name is taken
unregisterNetworkListener(channel) stops listening

Sending returns false when the network is off or unreachable, or when the channel or payload is refused. True only means Voxen took it, not that it landed: the send is queued and never blocks the caller.

The listener runs on the server thread, so Bukkit calls are safe from it. Voxen checks the signature and drops replays and duplicates first, and a server never hears its own messages, so you do not need to filter yourself out.

VoxenApi.reportCase(id).thenAccept(loaded -> {
for (ChatLine line : loaded.getContext()) {
boolean reported = line.getId().equals(loaded.getReport().getMessageId());
}
});

reportCase is report plus the chat before and after the reported message and the audit trail of what moderators did to it — everything a moderation panel outside the game needs in one call. How much context comes back is context in modules/reports.yml.

deleteReportedMessage(id, moderator) deletes the message a report points at and records it in the audit trail. It only works while the message is still deletable: signed messages live in memory on the server they were typed on, so it answers false after a restart and from another server on the network.

VoxenApi.warn(uuid, "Steve", "spam", moderator);
VoxenApi.warnings(uuid).thenAccept(list -> ...);
List<MuteInfo> mutes = VoxenApi.activeMutes(uuid);
VoxenApi.tickets(TicketInfo.Status.ACTIVE, 25).thenAccept(queue -> ...);
VoxenApi.replyToTicket(ticketId, "Looking into it", moderator);
VoxenApi.closeTicket(ticketId, moderator);

warn goes through the same path as the warn command: it lands on the record, the warning rules for that count run, and PlayerWarnEvent fires first. activeMutes reads from memory, which is why it is the one moderation method without a future. The ticket methods are empty while modules/helpop.yml runs in broadcast mode.

Every moderation method takes a moderator name and stores it as written, so an addon acting for a Discord user, a web dashboard or a bot passes its own identity:

String who = VoxenApi.externalModerator("discord", "123456789"); // "discord:123456789"
VoxenApi.updateReport(id, ReportInfo.Action.CLAIM, who);

That name comes back on the matching event, and that is the loop guard: check the prefix before reacting, or your addon will answer its own actions.

discord:123456789 is fine in a database and useless on a screen, so register a resolver and Voxen prints something readable instead:

VoxenApi.registerModeratorResolver("discord", id -> lookup(id)); // "4g0 (Naimad123)"

It is used everywhere Voxen prints who did what — report and ticket screens, the web panel, audit trails, mute and warning lists — while events and the API keep handing back the stored string, so the prefix stays usable as a loop guard. Return null to leave a name as it was stored, which is what happens for an id you do not know.

The resolver runs on whichever thread is drawing, possibly once per row, so keep it fast, keep it thread safe and cache on your side. Unregister it when your plugin disables.

@EventHandler
public void onReportUpdate(ReportUpdateEvent event) {
if (event.getModerator().startsWith("discord:")) return; // we did this ourselves
mirrorToDiscord(event);
}
VoxenApi.registerPanelPage("shop", "Shop", "shop.view", new PanelPage() {
@Override
public String render(PanelRequest request) {
return PanelHtml.heading("Shop")
+ PanelHtml.table(
List.of("Item", "Seller", "State"),
sales().stream().map(sale -> List.of(
PanelHtml.Cell.of(sale.item()),
PanelHtml.Cell.of(sale.seller()),
PanelHtml.Cell.html(PanelHtml.tag("paid", PanelHtml.Style.GOOD))
)).toList());
}
});

The page appears in the panel sidebar as Shop for every account whose permissions in modules/web.yml cover shop.view, and answers with 403 for the rest. Return the body of the page only, Voxen wraps it in the panel frame.

PanelHtml emits the same markup as the bundled pages, so a page written with it follows the panel theme and keeps following it when the theme changes. Reaching for the raw CSS class names still works, but they are internal and can move.

Builder What it gives you
heading, section the page title and the smaller grey separators
card, notice, muted the rounded surface, a centred message, small grey text
table a table in a card, taking Cell values
tabs the row of filter links above the content
tag a coloured pill: NEUTRAL, INFO, GOOD, WARN, BAD
form, button, field a form that carries the CSRF token on its own
link, escape a link, and the escaper behind all of the above

Cell.of escapes the text it is given, Cell.html takes markup you built yourself. Prefer Cell.of and reach for Cell.html only to nest another builder, otherwise a nickname is enough to inject markup into the panel.

Forms built with PanelHtml.form already carry the token the panel demands, so they are never rejected as expired. Read the fields back in submit with request.param:

@Override
public String render(PanelRequest request) {
return PanelHtml.form(request,
PanelHtml.field("item", "Item name", "")
+ PanelHtml.button("action", "restock", "Restock"));
}
@Override
public void submit(PanelRequest request) {
if ("restock".equals(request.param("action"))) restock(request.param("item"));
}

render and submit cover an HTML page in the panel frame. Override handle for anything else and return a PanelResponse. Returning null falls back to render and submit, which is what happens when you do not override it at all.

@Override
public PanelResponse handle(PanelRequest request) {
List<String> path = request.getPath();
if (path.isEmpty()) return null;
return switch (path.get(0)) {
case "item" -> PanelResponse.json(itemAsJson(path.get(1)));
case "export" -> PanelResponse.download("sales.csv", "text/csv", csv());
case "old" -> PanelResponse.redirect(request.linkTo("shop", List.of(), Map.of()));
default -> PanelResponse.status(404, "No such item.");
};
}
Response What it does
page HTML wrapped in the panel frame, same as render
status a short message in the frame under an HTTP status
json, text a body served as it is, outside the frame
raw any other content type, outside the frame
download a file the browser saves instead of showing
redirect sends the browser to another panel path

request.getPath() holds the segments after the page id, so /shop/item/42 gives ["item", "42"]. request.getMethod() tells GET from POST, and a POST reaching handle has already passed the CSRF check.

Markup content types are refused and redirects have to stay inside the panel. Both would let a page run scripts in the panel’s own origin or bounce a logged in moderator somewhere else, and the panel has no other defence against that.

Pass a Supplier<String> instead of a plain title and the sidebar entry follows your config on every reload. Override enabled to hide a page while the feature behind it is off; it disappears from the sidebar and answers with 404 without being unregistered.

VoxenApi.registerPanelPage("shop", () -> config.getString("panel.title"), "shop.view", page);

Pages are not written to disk. Register them in onEnable and drop them with unregisterPanelPage in onDisable; the two built-in pages cannot be removed this way.

Two events cover the message lifecycle. See Events.