Skip to content

Expansions

Pavel Erokhin edited this page Oct 25, 2026 · 3 revisions

The expansion API lets your plugin share its data through WorldAPI with a few typed classes. You list resources clients may read and the events you publish; WorldAPI serves them at /api/v1/extensions/<id>/…, streams the events on the WebSocket, keeps the recorded ones in its history, and the MCP server reads them through its list_extensions and read_extension tools. Your plugin stays the source of truth: WorldAPI reads a resource when a client asks and stores none of it.

The library is plain Java without dependencies, so it works from Java, Kotlin, or any JVM language, and the same expansion will run on the Fabric and NeoForge builds of WorldAPI once they arrive. It's the only way since WorldAPI 4.0.0, which removed the older map-based bridge in Bukkit's ServicesManager; Extensions shows what clients read.

1. Add the library

com.mairwunnx:worldapi-expansion is on Maven Central. Add it as compileOnly: WorldAPI's jar carries the same classes, so yours never should.

// build.gradle.kts
repositories { mavenCentral() }
dependencies { compileOnly("com.mairwunnx:worldapi-expansion:1.0.0") }
<!-- pom.xml -->
<dependency>
  <groupId>com.mairwunnx</groupId>
  <artifactId>worldapi-expansion</artifactId>
  <version>1.0.0</version>
  <scope>provided</scope>
</dependency>

The API has its own version, apart from WorldAPI's. Within a major version it only grows, so an expansion built against 1.0 runs on every WorldAPI with API 1.x. WorldAPI 3.4.0 brings 1.0.

2. See WorldAPI's classes

Your plugin needs WorldAPI's copy of the classes. In plugin.yml, list it in softdepend (or depend, if your plugin can't run without it):

softdepend: [WorldAPI]

A Paper plugin joins its class path in paper-plugin.yml:

dependencies:
  server:
    WorldAPI:
      load: BEFORE
      required: false
      join-classpath: true

3. Write the expansion

package com.example.bosses;

import com.mairwunnx.worldapi.expansion.EventType;
import com.mairwunnx.worldapi.expansion.ExpansionApi;
import com.mairwunnx.worldapi.expansion.Resource;
import com.mairwunnx.worldapi.expansion.WorldApiExpansion;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;

public final class BossesExpansion implements WorldApiExpansion {
    private final BossesPlugin plugin;

    public BossesExpansion(BossesPlugin plugin) {
        this.plugin = plugin;
    }

    @Override
    public String id() {
        return "bosses"; // /extensions/bosses/... and the topics ext.bosses.*
    }

    @Override
    public String requiresApi() {
        return ExpansionApi.VERSION;
    }

    @Override
    public String description() {
        return "Bosses and their hunters";
    }

    @Override
    public List<Resource> resources() {
        return List.of(
            // Off the server thread: the handler reads the plugin's own thread-safe data or its database.
            Resource.of("hunters", "The best hunters; ?limit=1..50", request ->
                plugin.top(request.query("limit", 10, 1, 50))).publicly(),
            // A CompletionStage lets the database answer on its own thread; WorldAPI waits up to 10 seconds.
            Resource.of("players/{player}", "A player's card: level and kills", request ->
                plugin.loadCard(request.path("player")).thenApply(card -> {
                    if (card == null) throw new NoSuchElementException("No hunter named " + request.path("player"));
                    return Map.of("name", card.name(), "level", card.level(), "kills", card.kills());
                })).publicly(),
            // On the server thread, since it reads entities. Coordinates only for those who may see them.
            Resource.of("bosses", "The bosses alive, with health", request ->
                plugin.aliveBosses().stream().map(boss -> request.seesCoordinates()
                    ? Map.of("type", boss.type(), "health", boss.health(), "location", boss.location())
                    : Map.of("type", boss.type(), "health", boss.health())).toList()).onServerThread());
    }

    @Override
    public List<EventType> events() {
        return List.of(
            EventType.of("boss.killed", "Players killed a boss").publicly().recorded(),
            EventType.of("boss.spawned", "A boss appeared"));
    }
}

4. Register it

Either register in code, only when WorldAPI is there:

@Override
public void onEnable() {
    if (getServer().getPluginManager().getPlugin("WorldAPI") != null) WorldApiExpansions.register(new BossesExpansion(this));
}

Or let WorldAPI find it: put your class's full name in src/main/resources/META-INF/services/com.mairwunnx.worldapi.expansion.WorldApiExpansion. WorldAPI then makes it with its public constructor without arguments when your plugin is enabled, and nothing in your code mentions WorldAPI. An expansion found this way can reach your plugin through JavaPlugin.getPlugin(BossesPlugin.class).

Either way WorldAPI drops your expansion when your plugin stops; WorldApiExpansions.unregister("bosses") does it earlier. The log says what it took:

[WorldAPI] Bosses shares bosses: 3 resources, 2 topics

5. Publish events

WorldApiExpansions.publish("bosses", Event.of("boss.killed", Map.of("type", "zombie", "level", 40))
    .player(player.getUniqueId(), player.getName())
    .world(player.getWorld().getName()));

publish is safe from any thread; the event reaches clients from the server thread. It returns false, and drops the event, while WorldAPI isn't running, so you can call it without checking. An event your expansion doesn't list throws IllegalArgumentException.

Resources

  • A path is segments of a-z 0-9 _ -, or {name} for a parameter, which request.path("name") gives back.
  • request.query("limit") reads a query parameter, null when it's missing; request.query("limit", 10, 1, 50) reads a whole number with a default and bounds, and answers 400 for anything else.
  • request.anonymous(), request.can("players:private"), and request.seesCoordinates() say who asks, so one resource can show more to some clients. Tokens and permissions lists the permissions.
  • A handler returns plain Java values: maps, lists, arrays, strings, numbers, booleans, and null. UUIDs, enums, and times turn into strings, anything else into its toString(). A CompletionStage of those lets you finish on your own thread.
  • Throw IllegalArgumentException for 400 and NoSuchElementException for 404, each with a short message for the client. Any other exception answers 502.
  • Handlers run on a worker thread unless the resource calls onServerThread(). There, keep them quick and never wait on the network or a database.

Events

Each type becomes the topic ext.<id>.<name>, like ext.bosses.boss.killed. recorded() keeps the events in GET /events; publicly() lets visitors subscribe. Visitors never get the coordinate keys in the data (location, position, and the like) unless the server's owner opened coordinates to them, and an event about a vanished player shows only to clients with players:moderate, as WorldAPI's own do.

Privacy

A resource or a topic needs a token unless it's publicly(). Even then, the server's owner can hide your namespace, one resource (bosses/bosses), or one topic in extensions.private in WorldAPI's configuration. Never make public what a visitor shouldn't see: coordinates, inventories, addresses, or chat.

Rules WorldAPI checks

  • The id is 1 to 32 of a-z 0-9 _ -, and one plugin owns it: another plugin can't take it.
  • requiresApi() must be a version this WorldAPI supports: the same major, and a minor no higher than its own.
  • An event type appears once.

WorldAPI refuses a broken expansion with a line in its log, and your plugin keeps running.

Fabric and NeoForge

The API doesn't depend on Bukkit, Fabric, or NeoForge, so one expansion class serves every platform. WorldAPI's mods find expansions the same way, through META-INF/services in any mod's jar or WorldApiExpansions.register. Depend on com.mairwunnx:worldapi-expansion with compileOnly (Loom's modCompileOnly isn't needed: it's plain Java), and add WorldAPI to your mod's optional dependencies so it loads first. In singleplayer, WorldAPI starts with each world you open and stops when you leave. A registered expansion stays registered in between and reaches every next world; publish returns false while no world is open, and available() says whether one is.

Next: Java scripts.

Clone this wiki locally