A command is almost everyone's first plugin. You want to type /kit pvp and have something happen. The Java that does the work is thirty lines. The Java is never the problem: the problem is the chain around it, and above all a six-line file that everyone forgets the first time.
This guide walks the chain in the exact order the server walks it: declaration, executor, arguments, tab completion, permissions, denial message. And it ends with the diagnostic table I wish somebody had handed me on day one, mapping what the player sees against what the console says for every way of getting it wrong.
The silent failure, explained once and for all
Here is what happens when a command is not declared in plugin.yml. The plugin loads normally. It shows up green in /plugins. The console says nothing at all. The player types the command and the server answers with its vanilla unknown-command message. Your code was never reached, and nothing anywhere tells you so.
That is what sets this apart from every other plugin failure. A wrong Java version throws UnsupportedClassVersionError at you. A missing dependency throws NoClassDefFoundError. A command that was never declared throws nothing. So the beginner concludes the Java is broken and rewrites it three times, when the Java was correct all along.
The habit that saves you two hours
The full chain, in server order
When a player sends a command, the server always follows the same steps. Knowing them in order means knowing which link broke.
- Registration. At plugin load, the server reads the
commands:block of plugin.yml and creates one command per entry. Nothing here means nothing exists. - Resolution. The server looks up the typed name, or one of its aliases.
- Permission test. If the
permission:field is declared and the sender lacks it, the server prints the denial and stops. Your code is not called. - Executor call. Your
onCommandreceives the sender, the command, the label that was actually typed, and the argument array. - Return value.
truemeans you handled it,falsemakes the server print theusage:line.
Tab completion runs on a parallel path: it fires on every keystroke, before anything is sent, through a different method. API references live on docs.papermc.io and the javadoc on jd.papermc.io.
Step 1: declare the command in plugin.yml
Here is a complete plugin.yml for a /kit command with an alias, a usage line, a permission and a denial message.
name: MinaxKit
version: 1.0.0
main: fun.minax.kit.MinaxKit
api-version: '1.21'
description: Hands out kits to players.
commands:
kit:
description: Give a kit to a player.
usage: /kit <starter|pvp|builder> [player]
aliases: [k]
permission: minaxkit.use
permission-message: You do not have access to kits.
permissions:
minaxkit.use:
description: Allows using /kit on yourself.
default: true
minaxkit.other:
description: Allows /kit <name> <other player>.
default: op
Three details break everything and are invisible to the naked eye. The command name is a YAML key, so indentation matters: two spaces under commands:, never a tab. The file must sit at the root of src/main/resources, not next to your class. And api-version has to be a quoted string, otherwise YAML reads 1.21 as a decimal number.
The number that decides your api-version
Step 2: the executor, and the getCommand trap
The default executor of a command is the plugin itself. In other words, if you write onCommand straight into your main class, it works without registering anything. That is convenient, and it is exactly what makes the silent failure so nasty: with no call to getCommand, nothing ever verifies that the command exists on the server side.
My take, and it is a firm one: always register explicitly, even from the main class, and handle the null. You are trading a mute failure for a loud one, which is an enormous upgrade.
package fun.minax.kit;
import org.bukkit.command.PluginCommand;
import org.bukkit.plugin.java.JavaPlugin;
public final class MinaxKit extends JavaPlugin {
@Override
public void onEnable() {
KitCommand handler = new KitCommand();
PluginCommand command = getCommand("kit");
if (command == null) {
getLogger().severe("Command 'kit' is missing from plugin.yml, shutting the plugin down.");
getServer().getPluginManager().disablePlugin(this);
return;
}
command.setExecutor(handler);
command.setTabCompleter(handler);
getLogger().info("Command /kit registered.");
}
}
That last getLogger().info is not decoration. It is your visual proof at startup: if you do not see the line in the console, the problem sits upstream of the Java.
Step 3: arguments, an array with no guarantees
The argument array holds whatever the player typed after the command name, split on spaces. It can be empty. It can hold anything. Nothing is validated for you, and reading args[0] on an empty array throws an exception the player will see as a generic internal error.
package fun.minax.kit;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
import org.bukkit.util.StringUtil;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
public final class KitCommand implements CommandExecutor, TabCompleter {
private static final List<String> KITS = List.of("starter", "pvp", "builder");
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (args.length == 0) {
return false; // the server prints the usage: line from plugin.yml
}
String kit = args[0].toLowerCase(Locale.ROOT);
if (!KITS.contains(kit)) {
sender.sendMessage(Component.text("Unknown kit: " + args[0], NamedTextColor.RED));
return true;
}
Player target;
if (args.length >= 2) {
if (!sender.hasPermission("minaxkit.other")) {
sender.sendMessage(Component.text("You can only give a kit to yourself.", NamedTextColor.RED));
return true;
}
target = Bukkit.getPlayerExact(args[1]);
if (target == null) {
sender.sendMessage(Component.text(args[1] + " is not online.", NamedTextColor.RED));
return true;
}
} else if (sender instanceof Player self) {
target = self;
} else {
sender.sendMessage(Component.text("From the console: /kit <name> <player>", NamedTextColor.RED));
return true;
}
target.sendMessage(Component.text("Kit " + kit + " delivered.", NamedTextColor.GREEN));
return true;
}
// The class continues in step 4: onTabComplete is required by TabCompleter.
Two things worth noticing. The early return false is not an admission of failure, it is the built-in help mechanism printing your usage: line. And the sender is not necessarily a player: the console can send the command, so can a command block. The instanceof Player check is not optional, and without it your command crashes the first time an admin runs it from the console.
Step 4: tab completion
This is the part everybody skips, and it is the visible difference between a hobby plugin and one people enjoy using. A player who presses Tab and sees nothing appear concludes the command does not exist.
@Override
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
if (args.length == 1) {
List<String> matches = new ArrayList<>();
StringUtil.copyPartialMatches(args[0], KITS, matches);
Collections.sort(matches);
return matches;
}
if (args.length == 2 && sender.hasPermission("minaxkit.other")) {
return null; // server fallback: names of online players
}
return Collections.emptyList();
}
}
copyPartialMatches filters your list against what the player has already typed, case insensitively. The detail that actually matters is the difference between null and an empty list. Returning null enables the server fallback, which completes with the names of online players. Handy on the second argument, an information leak everywhere else. Default to an empty list and opt into null deliberately.
Step 5: permissions and the denial message
The counter-intuitive part: when you declare permission: in plugin.yml, the server tests it before calling your executor. A hasPermission check for that same node inside onCommand is therefore dead code. It stays essential for argument-level permissions, like the minaxkit.other node above.
That has a direct consequence for the denial message. Keep permission: in plugin.yml and your message is whatever sits in permission-message, as plain text. Want a rich denial, properly coloured through Adventure or clickable? Then drop permission: from plugin.yml and run the check yourself.
// Variant: permission NOT declared in plugin.yml, denial fully under your control
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!sender.hasPermission("minaxkit.use")) {
sender.sendMessage(Component.text("Kits are for members only.", NamedTextColor.RED));
return true;
}
// ... rest of the handling
return true;
}
My call: I keep permission: in plugin.yml for admin commands, because the server then hides the command from help output and blocks as early as possible. I drop it for player-facing commands, where the denial message is part of the experience. And I always declare the permissions: block with an explicit default:, otherwise a manager such as LuckPerms will not surface your node until somebody assigns it by hand.
The diagnostic table
This is the thing I have never seen written down plainly, and it identifies the failure in ten seconds: cross what the player sees with what the console says.
| What the player sees | What the console says | |
|---|---|---|
| Missing from plugin.yml | Unknown command (vanilla message) | Absolutely nothing |
| Declared, no executor set | The usage: line | Nothing |
| getCommand null unhandled | Unknown command | Startup exception, plugin disabled |
| Permission missing | The permission-message | Nothing |
| onCommand returns false | The usage: line | Nothing |
| Exception inside onCommand | A generic internal error | Full stack trace naming your class |
| Name already taken | The other plugin's command | A conflict line at load time |
The prefix that settles conflicts
What does not work, and what you will break
Four traps that hit commands specifically, three of which have nothing to do with the command code itself.
- The denial message is what breaks on upgrade. Paper 26.x ships Adventure 5, which removed APIs that were previously only deprecated, and the removals land hardest on anything that puts text in front of a player. An onCommand written for 1.21 with legacy text messages may simply refuse to compile. It is ironic: the most trivial part of your command is the most exposed part.
- A command that reads a database freezes the server. onCommand runs on the main thread. A synchronous database read in there drags the TPS down for everyone. The SQLite JDBC driver already ships with Paper, so there is nothing to bundle, but the read has to go async and come back to the server thread before it touches the world.
- On Folia, your command cannot assume a single thread. Folia runs world regions across several threads. A command that teleports, places a block or edits an inventory has to go through the RegionScheduler, or it crashes the server.
- Your per-player storage key is a design trap. 75.1 % of servers run in offline mode, and in offline mode a player's UUID is derived from their name. If your /kit stores a cooldown keyed by UUID, a name change or a future switch to online mode orphans every row you wrote. Plan for it in version one, not the evening a player asks where his rank went.
Do not reload, restart
My verdict: when to stay on plugin.yml
I stay on the path described here, a commands: block plus an executor, up to three subcommands and two arguments. Below that threshold everything else is needless complexity: one class, zero dependencies, zero licensing risk.
Above it, once you are writing condition chains on args[0] to tell give from list from reload from help, I move to a command framework such as Lamp, which carries a permissive licence and is therefore safe to redistribute. The trigger is not the player count, it is the branch count: at the third else if on args[0], the framework starts paying for itself.
What I do not recommend is reaching straight into the server's low-level command tree to get coloured argument hints. It works, it looks impressive, and it welds you to an internal layer that moves between versions. Minecraft has been deobfuscated since 26.1 and Paper's remapper is gone, which simplifies a lot, but an internal API is still an internal API.
A complete command, generated and compiled
Describe the command you want, its arguments and its permissions. Minax writes the plugin.yml, the executor and the tab completion, then hands you a tested .jar.
Create my commandFrequently asked questions
Why does my command do nothing at all, with no error anywhere?
Because it is not declared in the commands block of plugin.yml. The server has no idea your command exists, it replies with the vanilla unknown-command message, and your plugin is never called. Nothing appears in the console: this is the hardest plugin failure for a beginner to diagnose, precisely because there is nothing to read.
Do I need getCommand().setExecutor() if onCommand lives in my main class?
Technically no. The default executor of a command is the owning plugin, so overriding onCommand in the main class already works. I still recommend registering explicitly: if the command is missing from plugin.yml, getCommand returns null and you get a loud startup failure instead of total silence.
What happens when onCommand returns false?
The server shows the player the usage line declared in plugin.yml. It is not an error signal, it is the built-in help mechanism. Return false for wrong usage, and true once you have sent your own message.
How do I stop tab completion from listing every online player?
Return an empty list instead of null. When onTabComplete returns null, the server applies its fallback and completes with the names of online players, which hands the connected-player list to anyone who presses Tab.
Where should the no-permission message live?
Two options. Either the permission-message field in plugin.yml, in which case the server blocks the command before your code runs. Or you drop the permission field from plugin.yml and test hasPermission yourself, which gives you full control of the wording and the formatting.
Which api-version should I declare in July 2026?
The 1.21 family is still the majority of the real server population: 1.21.11 accounts for 36.4 % of measured servers, against 14.9 % for 26.1.2 and 7.4 % for 26.2. Declaring an api-version that is too recent locks out most servers. I stay on the 1.21 family until the calendar versions take a third of the population.
Keep going: the list of errors that stop a plugin from loading, the guide to generating a minimal Spigot plugin, and the API references on docs.papermc.io, hub.spigotmc.org and minecraft.wiki. The version shares quoted above come from the public bStats sample of July 2026.


