Skip to content

Expansions

Pavel Erokhin edited this page Oct 24, 2026 · 5 revisions

Expansions

An expansion lets your plugin or mod teach Allay about what it adds: tools the AI calls to read or do something in your plugin, items and recipes Allay can't see on its own, and a few lines of advice for the model. You write one class against a small Java library, and the same class works on Paper, Spigot, Fabric, and NeoForge.

Allay finds most things by itself. It reads the server's registries and recipes, mods' items and data packs' recipes included, so a mod with real items needs no expansion for them. Write one when you want the model to call your code, when your items are the game's own underneath (a Paper plugin's potion with your model), or when your recipes match by code, like a Paper PotionMix with predicates.

What you get

  • Tools. The model calls x_<your id>_<tool name> with arguments from your schema; your code runs on the server thread and returns data, now or, for work on the network or a database, later from its own thread. A player sees a line “Asking Your Plugin” and your short summary under it.
  • Items and recipes. Names in every language the game has, a description, and crafting, cooking, stonecutting, and brewing recipes. search_items, get_recipes, and get_brewing_recipe answer with them.
  • Prompt lines. Up to 2000 characters the model reads under “## Your Plugin's plugin prompt”: what your plugin adds and how to help players with it.

The dependency

The library is chat.allay:allay-expansion on Maven Central, plain Java with no dependencies. Its classes come with Allay at runtime, so compile against it and never shade it into your jar.

Gradle (Kotlin DSL):

repositories {
  mavenCentral()
}

dependencies {
  compileOnly("chat.allay:allay-expansion:1.3.0")
}

Maven:

<dependency>
  <groupId>chat.allay</groupId>
  <artifactId>allay-expansion</artifactId>
  <version>1.3.0</version>
  <scope>provided</scope>
</dependency>

Allay 6.12 and newer carries API 1.0. The versions section says what runs where.

Your expansion

One class implements AllayExpansion. This one gives a tool that reads a player's mana and tells the model about it, in Java:

package com.example.mana;

import chat.allay.expansion.*;
import java.util.List;
import java.util.Map;

public abstract class ManaExpansion implements AllayExpansion {
  /** How each platform reads a player's mana. */
  protected abstract int mana(Asker asker);

  @Override public String id() { return "mana"; }
  @Override public String requiresApi() { return AllayApi.VERSION; }
  @Override public String name() { return "Mana"; }

  @Override public List<AllayTool> tools() {
    return List.of(new AllayTool() {
      @Override public String name() { return "status"; }
      @Override public String description() {
        return "The asking player's mana: current, max (100), and seconds until full. Call it before advising a spell.";
      }
      @Override public ToolResult run(ToolCall call) throws ToolException {
        int mana = mana(call.asker());
        return ToolResult.of(Map.of("current", mana, "max", 100, "seconds_to_full", (100 - mana) * 2)).summary(mana + " / 100");
      }
    });
  }

  @Override public String promptExpansion() {
    return "Mana adds spells cast with a wand. Each spell costs mana, which refills by 1 every 2 seconds. Before you suggest a spell, call x_mana_status.";
  }
}

The same in Kotlin:

abstract class ManaExpansion : AllayExpansion {
  protected abstract fun mana(asker: Asker): Int

  override fun id() = "mana"
  override fun requiresApi(): String = AllayApi.VERSION
  override fun name() = "Mana"

  override fun tools(): List<AllayTool> = listOf(object : AllayTool {
    override fun name() = "status"
    override fun description() = "The asking player's mana: current, max (100), and seconds until full. Call it before advising a spell."
    override fun run(call: ToolCall): ToolResult {
      val mana = mana(call.asker())
      return ToolResult.of(mapOf("current" to mana, "max" to 100, "seconds_to_full" to (100 - mana) * 2)).summary("$mana / 100")
    }
  })

  override fun promptExpansion() = "Mana adds spells cast with a wand. Each spell costs mana, which refills by 1 every 2 seconds. Before you suggest a spell, call x_mana_status."
}

requiresApi() returns AllayApi.VERSION: the compiler copies the constant into your class, so Allay learns which version you built against.

The shared class stays abstract, and each platform hands in what only it knows, through call.asker().unwrap(...): Bukkit's Player on Paper and Spigot, the game's ServerPlayer on Fabric and NeoForge.

On each platform

Allay finds your class through Java's service loader: name it in META-INF/services/chat.allay.expansion.AllayExpansion in your jar, one class per line. Nothing in your code then touches Allay's classes unless Allay is installed, so your plugin runs without it. The class needs a constructor without arguments.

Paper and Spigot

public final class PaperManaExpansion extends ManaExpansion {
  @Override protected int mana(Asker asker) {
    Player player = asker.unwrap(Player.class);
    return player == null ? 0 : ManaPlugin.mana(player);
  }
}

src/main/resources/META-INF/services/chat.allay.expansion.AllayExpansion:

com.example.mana.PaperManaExpansion

In plugin.yml, so your plugin loads after Allay and sees its classes:

softdepend: [Allay]

A Paper plugin with paper-plugin.yml needs Allay on its class path:

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

Allay looks into each plugin as it's enabled, and drops its expansions when it's disabled.

Fabric

public final class FabricManaExpansion extends ManaExpansion {
  @Override protected int mana(Asker asker) {
    ServerPlayer player = asker.unwrap(ServerPlayer.class);
    return player == null ? 0 : ManaComponents.mana(player);
  }
}

The same services file, with com.example.mana.FabricManaExpansion. In fabric.mod.json, say Allay is welcome but not needed:

"suggests": { "allay": "*" }

NeoForge

public final class NeoForgeManaExpansion extends ManaExpansion {
  @Override protected int mana(Asker asker) {
    ServerPlayer player = asker.unwrap(ServerPlayer.class);
    return player == null ? 0 : player.getData(ManaAttachments.MANA);
  }
}

The same services file, with com.example.mana.NeoForgeManaExpansion. In neoforge.mods.toml:

[[dependencies.mana]]
modId = "allay"
type = "optional"
versionRange = "*"
ordering = "NONE"
side = "SERVER"

Registering in code

Instead of the services file, call AllayExpansions.register(expansion) when your plugin enables or your mod's server starts, and AllayExpansions.unregister("mana") when it stops. Check that Allay is there first, or the class won't load:

if (getServer().getPluginManager().getPlugin("Allay") != null) AllayExpansions.register(new PaperManaExpansion());

An expansion registered once stays for every Allay the game starts: in singleplayer, each world starts its own.

Allay reads your tools, items, and prompt when you register and on /allay reload, and keeps them. When your items change while the server runs (a config of your own reloaded), call AllayExpansions.register again with the same id: Allay reads it anew and rebuilds its catalog.

Tools

Part What Allay takes
name() 1–32 lower-case letters, digits, and underscores. The model sees x_<id>_<name>; your expansion's id() is 1–24 of the same
description() 1–1000 characters of English for the model: what it does, what the result holds, and when to call it. The model reads only this before it calls
parameters() A Schema: Schema.arguments().string("can", "The can's id", true).integer("count", "How many, 1 to 64", false, 1, 64), with number and string bounds, object and objects (a list of objects, read back with call.args().objects(…)) since 1.1, or Schema.json("{…}") with a JSON Schema object, up to 4000 characters. Schema.none() for no arguments
access() READ (reads the game; the default), OWN (changes only the asker's own things), or OPERATOR (anything else; operators only)
permission() A permission the asker must also have, like mana.allay; where no permissions plugin or mod answers, everyone has it
offeredTo(asker) Whether to offer the tool to this asker now, beyond access and permission: only in your worlds, only while they hold your item. Asked on the server thread when a question starts and before each call; keep it quick. An exception counts as no. True by default; since 1.2
inPublic() Whether the tool may run in a question everyone online reads (/askall); false by default
run(call) Runs on the server thread. Return ToolResult.of(map) with strings, numbers, booleans, lists, and maps, and .summary("…"), a line under the tool in the asker's language, at most 60 characters. Name things in the result in call.asker().language() (since 1.3), the language Allay answers in, like ru_ru; locale() is their game's, which may differ. Throw ToolException("…") with a short reason the model can act on. Return ToolResult.later(stage) for an answer that comes later (since 1.1, below)

Keep results small: the model pays for every character on every step, and Allay cuts a result over 8000 characters. Use ids (redbull:original), absolute coordinates, and numbers rather than prose. Keep the call quick: it holds the server thread, and Allay gives up after http.tool_timeout seconds.

Answers that come later

A tool that asks a database, a web service, or a file returns ToolResult.later(stage) (since 1.1): it reads what it needs from the game on the server thread, hands the rest to its own thread, and completes the stage there. The server goes on at once, and Allay waits for the stage off the server thread, up to the same http.tool_timeout, then cancels it.

@Override public ToolResult run(ToolCall call) {
  UUID player = call.asker().uuid();
  return ToolResult.later(CompletableFuture.supplyAsync(() -> {
    int mana = database.mana(player);
    return ToolResult.of(Map.of("mana", mana)).summary("mana " + mana);
  }, executor));
}

Off the server thread a tool may read the asker's uuid, name, locale, language, isOperator, dimension, and coordinates (taken when the call starts) and its arguments. hasPermission, unwrap, nameOf, and canSee read the game: call them before you hand work away. Complete the stage exceptionally with a ToolException to refuse.

The arguments come as the model wrote them. call.args() answers null for a missing argument or one of the wrong type, so check what you need.

What Allay checks for you

  • A tool runs only inside a question, after the player agreed to Allay's notice, and only when the backend offered it in that question.
  • OPERATOR tools are offered to operators only and checked again before each call; automation agents never get them. Only READ tools run in /askx, and none run for the console or an automation agent.
  • Your permission() and offeredTo(asker) are checked before the tool is offered and again before it runs.
  • An exception other than ToolException reaches the model as “failed”, and the server's log names your tool.

What you check yourself

  • A tool sees the asker. Other players reach the model only by call.nameOf(uuid, name) (their name when they agreed to Allay's notice here, else “Player N”), and only when call.canSee(uuid) says the asker may know of them. Don't hand out their coordinates, inventories, or anything private.
  • A tool that gives items or changes the world for the asker is OPERATOR. Survival players can't talk the AI into gear.
  • The words in your description and results are yours. The model reads them as data from a plugin and keeps Allay's own rules above them.

Items and recipes

items() returns what Allay should find by name and explain. An item has your namespaced id, its names by game language, a description, the game's item underneath when it's built on one, and recipes:

@Override public List<ItemInfo> items() {
  return List.of(
    ItemInfo.of("mana:crystal")
      .name("en_us", "Mana Crystal").name("ru_ru", "Кристалл маны")
      .description("Refills 50 mana when used")
      .base("amethyst_shard")
      .recipe(Recipe.shapeless(List.of(Ingredient.item("amethyst_shard"), Ingredient.item("lapis_lazuli")), 1)),
    ItemInfo.of("mana:potion")
      .name("en_us", "Potion of Mana")
      .base("potion")
      .recipe(Recipe.brewing(Ingredient.potion("awkward"), Ingredient.item("mana:crystal"))));
}

Recipes are shaped, shapeless, cooking (FURNACE, BLASTING, SMOKING, CAMPFIRE), stonecutting, and brewing, a bottle below and a reagent on top. An ingredient is an item (Ingredient.item), any of a few (anyOf), a tag (tag("planks")), or a bottle of a potion (potion("water")).

Describe only what Allay can't see. A mod's registered items and its data recipes are found without you, and an expansion's word on one of them only adds its names and description. On Paper, Allay knows a plugin's item built on the game's own by its item model (redbull:original for a potion with that model), and reads its names from assets/<namespace>/lang/<language>.json in your jar, so keep every language there. A potion mix that matches by a predicate shows Allay only what comes out: describe its brew.

Prompt lines

promptExpansion() returns plain English, at most 2000 characters, or null. The model reads it under “## Name's plugin prompt”, after Allay's own prompt, which keeps its rules on safety, privacy, operator tools, and the player's language first. Say what your plugin adds, how a player gets it, and when to call your tools.

Versions

Within a major version the API only grows, and new methods come with defaults:

  • An expansion built against 1.0 runs on every Allay with API 1.x. One that uses 1.1's ToolResult.later, nested arguments, or bounds needs Allay 6.14 or newer, 1.2's offeredTo Allay 6.15, and 1.3's Asker.language Allay 6.17. An expansion that requires an older version calls a newer method only where AllayApi.satisfies(AllayExpansions.apiVersion(), "1.3.0").
  • An expansion that needs a newer minor than the Allay it meets is refused, with a line in the log that names the version it needs, rather than failing on a missing method.
  • AllayExpansions.apiVersion() tells the version of the Allay running now, for a feature check.

For server owners

Allay logs each expansion as it comes: Expansion Mana 1.0.0 is on: 1 tool, 0 items, a prompt, or why it was refused. expansions.enabled: false in Configuration turns them all off, and expansions.disabled: [mana] leaves out one.

Next: Development

Clone this wiki locally