Repository navigation
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.
-
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, andget_brewing_recipeanswer 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 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.
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.
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.
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: trueAllay looks into each plugin as it's enabled, and drops its expansions when it's disabled.
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": "*" }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"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.
| 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.
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.
- 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.
-
OPERATORtools are offered to operators only and checked again before each call; automation agents never get them. OnlyREADtools run in/askx, and none run for the console or an automation agent. - Your
permission()andofferedTo(asker)are checked before the tool is offered and again before it runs. - An exception other than
ToolExceptionreaches the model as “failed”, and the server's log names your tool.
- 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 whencall.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() 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.
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.
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'sofferedToAllay 6.15, and 1.3'sAsker.languageAllay 6.17. An expansion that requires an older version calls a newer method only whereAllayApi.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.
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