The API

holopanels-api has four extension points. Between them they cover everything a view can ask for that HoloPanels cannot know itself.

InterfaceSupplies
EntryProviderrows for a list panel
ContentProviderlines for a text panel
ConditionEvaluatora custom visible-if condition
ActionHandlera custom click action

Getting the API

Published through Bukkit's service manager, so the dependency is explicit and it unregisters cleanly.

<dependency>
    <groupId>dev.bwmp</groupId>
    <artifactId>holopanels-api</artifactId>
    <version>1.0.0</version>
    <scope>provided</scope>
</dependency>
HoloPanels api = Bukkit.getServicesManager().load(HoloPanels.class);
# plugin.yml
depend: [HoloPanels]

Feeding a list

An EntryProvider returns entries asynchronously. Each entry has an id, a label, any fields you like and any attributes you like.

NamespacedKey topBalances = new NamespacedKey(this, "top_balances");

api.registerEntryProvider(this, topBalances, request ->
    CompletableFuture.supplyAsync(() -> economy.topBalances(100).stream()
        .map(row -> PanelEntry.builder(row.id(), Component.text(row.name()))
            .field("rank", Component.text("#" + row.rank()))
            .field("balance", Component.text(row.formatted()))
            .attribute("staff", String.valueOf(row.isStaff()))
            .build())
        .toList()));

The view then names it:

board:
  type: list
  source:
    provider: yourplugin:top_balances
  row: "<gray><entry:rank> <white><entry:label> <dark_gray>· <gray><entry:balance>"
  empty: "<gray>Loading…"

Field names are yours. <entry:balance> resolves whatever you attached under balance. label is always the entry's label, and attributes are the plain strings that entry-attribute conditions compare.

Feeding a text panel

ContentProvider is the same idea, returning lines rather than rows.

api.registerContentProvider(this, new NamespacedKey(this, "queue_status"),
    request -> CompletableFuture.completedFuture(List.of(
        Component.text("Position: " + queue.positionOf(request.player())),
        Component.text("Ahead of you: " + queue.ahead(request.player())))));
status:
  type: text
  content-provider: yourplugin:queue_status
  lines:
    - "<gray>Queue unavailable."

lines stays as the fallback for when the provider has nothing to say.

A custom condition

api.registerCondition(this, new NamespacedKey(this, "in_region"),
    context -> regions.contains(
        context.argument("region"), context.player().getLocation()));
visible-if:
  custom:
    id: yourplugin:in_region
    region: spawn

Every key other than id arrives as an argument.

A custom action

api.registerAction(this, new NamespacedKey(this, "join_queue"),
    context -> {
        queue.add(context.player(), context.argument("queue"));
        return CompletableFuture.completedFuture(ActionResult.success());
    });
clicks:
  left:
    - type: custom
      id: yourplugin:join_queue
      queue: pvp

Returning a failure stops the action chain there, the same as any built-in action, which is what makes a custom action composable with confirm-message and the rest.

Two rules

Ids must be in your own plugin's namespace. new NamespacedKey(this, …) gives you that for free, and it is what keeps two plugins from claiming the same provider name.

Everything you register is dropped when your plugin disables. A reload cannot leave a stale handler pointing at a dead classloader, and you do not have to write the teardown.

Keep the returned Registration when your addon needs to withdraw one extension before disable. Closing it is idempotent.

Controlling a viewer

The API can also refresh one viewer or every viewer of a board, open a particular view for one viewer, reset that viewer to the root view, or hide the board for that viewer. open and reset return false when the board or requested view is unavailable.

A note on Adventure

holopanels-api exposes Adventure's Component on its own surface, and HoloPanels is deliberately the Paper-only build of Keystone so that type is the server's Component rather than a relocated copy.

That matters to you as a provider author: you build components with the Adventure that Paper ships, exactly as you would anywhere else in your plugin. Had Adventure been relocated, every third-party provider would fail at runtime against a class name that looks entirely correct. CI asserts the jar contains no kyori classes for that reason.