Commands
One file per command. The module returns the command, or an array of them.
local Operator = require(game:GetService("ServerStorage").Operator)
local Command, Types = Operator.Command, Operator.Types
export type KickArgs = { target: Player, reason: string }
local kick = Command.new("kick") :: Operator.Builder<KickArgs>
return kick
:description("Kick a player")
:arg(Types.Player, "target", "Player to kick")
:arg(Types.String, "reason", "Reason shown to the player", { rest = true })
:permission("moderator")
:run(function(context, args)
args.target:Kick(args.reason)
context:reply(`kicked {args.target.Name}`)
end)Inside run, args.target is a Player and args.reason is a string. Already resolved, already checked. You never parse a string yourself.
KickArgs is the contract between the :arg calls and the handler. Each :arg adds a name, and KickArgs declares what that name will hold by the time run sees it. The field names must match the argument names, and each type must match what the argument type produces. Types.Player produces a Player, so target: Player. Get that right and your editor autocompletes args. correctly and flags a typo; get it wrong and the code still runs, but the types are lying to you.
Every method on the builder is listed in api/command. The ones you will reach for first:
:arg(type, name, description): a positional argument:flag(name, description, type?): a named--flag. Pass a type and it takes a value:permission(role): gates the command behind a role:description(text): whathelpshows
The :: cast is required
Write Command.new("kick") :: Operator.Builder<KickArgs>. The annotation form, local kick: Operator.Builder<KickArgs> = Command.new("kick"), fails on the new Luau solver.
The link between :arg(Types.Player, "target") and KickArgs.target: Player is not checked. Types are erased at runtime, so nothing can verify it. You get a typed handler, not verified end-to-end type safety.
Arguments
:arg takes a type, a name, a description, and options:
:arg(Types.Duration, "duration", "How long", { optional = true })
:arg(Types.String, "message", "What to say", { rest = true })
:arg(Types.Players, "targets", "Who", { variadic = true })| Option | Effect |
|---|---|
optional | the argument may be omitted |
rest | joins every remaining word into one string |
variadic | collects every remaining word into a list |
rest and variadic must be last, and a required argument cannot follow an optional one. Both are checked when you call :run(), so a badly-shaped command fails at load rather than the first time someone runs it.
Argument Types covers the nine built-ins and writing your own.
Flags
A flag is a named argument that can appear anywhere on the line:
:flag("silent", "Do not announce it")
:flag("reason", "Why", Types.String)ban bob --silent
ban bob --reason="spam bot"Without a type a flag is a presence flag. context.flags.silent is true when given and nil when not. With a type it takes a value, already transformed. Flag values always need =; see Command Syntax.
Replying, and failing
The context your handler receives is how you talk back:
:run(function(context, args)
if args.target == context.executor then
return context:error("you cannot kick yourself")
end
context:reply("done")
end)context:reply adds a line to the caller's console. context:error marks the command failed and shows that message. Use it for an expected failure the player should see.
If your handler raises instead, the caller gets a generic the command failed and the real error goes to the log, never to the player. Full surface in api/context.
Grouping
:group() nests a command so it reads as a path:
Command.new("ban"):group("admin") --> admin banLongest match wins, so registering both admin and admin ban means admin ban bob runs admin ban with bob as its argument.
Registering them
Point Start at the folder and Operator loads everything below it:
Operator.Start({
Commands = admin.Commands,
})It scans all descendants, so subfolders are fine. Group your files however you like.
Loading is all-or-nothing
If any module fails to require, returns the wrong shape, or collides with another command's path, nothing is registered and the error names every offending file.
To add a command after startup, use the handle Start returns:
local operator = Operator.Start({ ... })
operator.registry:registerCommand(myCommand)
operator.refreshAll()registerCommand adds it; refreshAll re-sends the manifest so it appears in open consoles. The full registry surface is in api/registry.
Running commands from your own code
You do not need the console. The dispatcher takes raw text:
local result = operator.dispatcher:run("kick @me being rude", player)
if not result.ok then
warn(result.message)
endOmit the player to run as the server. The result shape and every status value are in api/dispatcher.
Restricting who can run one
:permission("moderator") is the usual answer, and it gates the manifest too. A player without the role never learns the command exists.
When the condition is not "who is this person" but something about the moment, such as a round in progress or a place-specific check, use a guard:
:guard(function(context)
if RoundService.isActive() then
return true
end
return false, "only between rounds"
end)Returning false denies and the second value is shown to the player.
Guards fail closed
A guard that raises, or takes longer than its timeout, denies the command. It never lets one through by accident.
Permissions covers roles in full; api/command has the guard signature.
A complete one
kick is deliberately minimal. A real command usually has an optional argument, a flag and a condition. Here is one with all three:
local Operator = require(game:GetService("ServerStorage").Operator)
local Command, Types = Operator.Command, Operator.Types
export type FreezeArgs = { targets: { Player }, duration: number? }
local freeze = Command.new("freeze") :: Operator.Builder<FreezeArgs>
return freeze
:description("Stop players moving, optionally for a set time")
:arg(Types.Players, "targets", "Who to freeze")
:arg(Types.Duration, "duration", "How long, such as 30s; omit to freeze until thawed", {
optional = true,
})
:flag("quiet", "Do not tell them why")
:permission("moderator")
:guard(function(context)
if RoundService.isActive() then
return false, "not while a round is running"
end
return true
end)
:run(function(context, args)
for _, player in args.targets do
FreezeService.freeze(player, args.duration)
if context.flags.quiet == nil then
Notify.send(player, "You have been frozen by a moderator")
end
end
local who = `{#args.targets} player(s)`
context:reply(if args.duration then `froze {who} for {args.duration}s` else `froze {who}`)
end)All of these work:
freeze bob
freeze @all 30s
freeze bob,alice 5m --quietFour things worth noticing:
targetsis{ Player }, notPlayer, becauseTypes.Playersresolves several. That is what makes@alland comma lists work.durationisnumber?inFreezeArgsbecause it is{ optional = true }. The handler checks it before using it.context.flags.quietisnilortrue. A flag with no type is presence-only.- The guard runs before the handler and before any argument is applied, so a refusal freezes nobody.
Next
- Client Commands: commands that run on the player's machine
- Argument Types: the built-ins, and writing your own
api/command: every builder method, exhaustively