Skip to content

Declare a command surface

You want /scout table new <a channel>: one namespace people learn, an argument the platform draws a picker for, and an identifier it resolved rather than text somebody typed.

This is the declaring half. Reading what arrives is Read command arguments.

1. Reach the capability

Commands are optional, so ask for them rather than assuming:

cmds, ok := chatplatform.AsCommands(provider)
if !ok {
    // this platform has no commands, or you did not declare NeedCommands
    return
}

AsCommands answers false unless the provider implements it and you declared NeedCommands when you built the client. A capability you did not ask for stays unreachable even where the platform has it.

2. Ask what the platform can carry, before you build the spec

if !cmds.SupportsOption(chatplatform.OptionChannel) {
    // decide deliberately: fall back to OptionString and resolve it yourself,
    // or refuse to start and say which platform cannot do what you need
}

Do this before registering, not after. Where SupportsOption answers false, RegisterCommands refuses the spec. It does not quietly register a text box in place of your picker. That refusal is the point: a command that registers cleanly and can never complete is worse than one that never registers, because you find out about the first from a user.

OptionString is always supported. A platform that could not carry text could not carry a command at all.

3. Build the spec

A flat command carries options:

ask := chatplatform.CommandSpec{
    Name:        "ask",
    Description: "Put a question to Scout",
    Options: []chatplatform.CommandOption{
        {Name: "question", Description: "what to ask", Type: chatplatform.OptionString, Required: true},
        {Name: "private",  Description: "answer only to you", Type: chatplatform.OptionBoolean},
    },
}

Required options come first. Platforms refuse the registration otherwise, and so does Validate. A spec breaking the rule looks perfectly reasonable in Go, which is exactly why it is worth catching.

The seven types are OptionString (the zero value), OptionInteger, OptionNumber, OptionBoolean, OptionChannel, OptionUser and OptionRole. An option written before types existed keeps working: no Type means text.

4. Nest, when a flat name stops reading well

A command carries either options or a tree, never both. Subcommands and groups mix freely under one command:

scout := chatplatform.CommandSpec{
    Name:        "scout",
    Description: "Run a table",
    Subcommands: []chatplatform.Subcommand{
        {Name: "join",  Description: "Join the current session"},
        {Name: "leave", Description: "Leave the current session"},
    },
    Groups: []chatplatform.CommandGroup{{
        Name: "table", Description: "Manage a table",
        Subcommands: []chatplatform.Subcommand{{
            Name: "new", Description: "Create a table",
            Options: []chatplatform.CommandOption{
                {Name: "channel", Description: "where it runs", Type: chatplatform.OptionChannel, Required: true},
            },
        }},
    }},
}

That gives /scout join, /scout leave and /scout table new.

Options beside subcommands is refused, and not for tidiness: declaring subcommands makes the base command uninvokable, so a command carrying both registers without complaint and then does nothing.

Nesting stops there. A group holds subcommands; a subcommand holds options. Neither holds itself, so the illegal shape is one you cannot write.

5. Register the whole set

if err := cmds.RegisterCommands(ctx, []chatplatform.CommandSpec{ask, scout}); err != nil {
    return fmt.Errorf("register commands: %w", err)
}

RegisterCommands is declarative: it replaces the registered set entirely, is idempotent, and is safe to run on every start. There is no partial update, so the registered set cannot drift from the declared one. Passing an empty slice removes everything.

6. Let Validate tell you before the platform does

Every spec is validated before anything is sent. Each rule refuses something a platform refuses on the wire, where it arrives as a provider-shaped 400, after connecting, reading like a configuration fault rather than like your spec being wrong.

refused sentinel
a name outside 1–32 runes of lowercase letters, digits, - or _ ErrInvalidCommandName
a description outside 1–100 runes ErrInvalidDescription
more than 25 options, subcommands or groups at one level ErrTooManyOptions
options beside subcommands ErrOptionsWithSubcommands
a group with nothing in it ErrEmptyGroup
a required option after an optional one ErrOptionOrder
an OptionType this contract does not define ErrUnknownOptionType
over 8000 runes of names, descriptions and choices combined ErrCommandTooLarge

Match them with errors.Is; the message names the command, subcommand or option at fault.

The name rule is narrower than Discord's, deliberately

Names take Unicode letters and digits, so искать and こまんど are fine. What they do not take is combining marks, which Devanagari and Thai use heavily: कमल and ไทย pass, while हिन्दी (the word "Hindi") does not.

That is a real cost and you cannot tell by looking which of your names it will refuse. The trade is that a refusal here is visible at Validate, where a name the platform rejects is a 400 on a live gateway. If it bites you, say so and it will be widened; the rule is conservative, not principled.

What is not here

No choice lists and no autocomplete. No dense argument syntax; flags like -t <target> -c <count> are issue #19 and deliberately deferred.