Skip to content

How a slash command is parsed

A chat application usually grows a few verbs that are not questions. Clear the conversation. Show what this has cost so far. List the tools the model can reach.

Something has to notice that /clear is one of those verbs rather than a message for the model. This page is about which piece of code does the noticing, because nearly everything else about slash commands here follows from that one choice.

Two places it could happen

Say someone types /clear. That string can be recognised in one of two places.

Design A: the chat library notices

Chat looks at what you passed it and sometimes does something other than send it:

// This is NOT what this library does. It is the design being turned down.
func (c *client) Chat(ctx context.Context, message string) (string, error) {
    if strings.HasPrefix(message, "/clear") {
        c.history = nil                    // intercepted here
        return "conversation cleared", nil // the model never sees it
    }

    return c.send(ctx, message)
}

This is genuinely appealing. An application gets commands for nothing: call Chat, and /clear works. Plenty of libraries do it.

Design B: your application notices

Chat always sends, whatever it is given:

// This IS what this library does.
func (c *client) Chat(ctx context.Context, message string) (string, error) {
    return c.send(ctx, message) // "/clear" is text, like anything else
}

and the checking happens in your own loop, before you call it:

result, handled, err := chat.RunCommand(ctx, client, commands, line)
if handled {
    fmt.Println(result.Text) // it was a command, and it has already run
    continue
}

reply, err := client.Chat(ctx, line) // not a command, so it is a message

This library is Design B. There is no hook, no middleware and no mode to turn on. RunCommand is an ordinary function that does nothing at all until you call it, and Chat has no idea commands exist.

Why B, given that A is less work

Design A holds up for exactly as long as the string you hand Chat is the string a person typed. Applications stop doing that early.

Consider one that summarises a file, with the instruction kept in the system prompt so the message is just the document:

client, _ := chat.New(ctx, chat.Settings{Config: chat.Config{
    Provider:     chat.ProviderGemini,
    SystemPrompt: "Summarise whatever the user sends.",
}})

content, _ := os.ReadFile(path)
reply, err := client.Chat(ctx, string(content))

Now imagine the file happens to begin with /clear. A saved transcript of another chat session would. So would a scratch file of commands someone was drafting. Under Design A the summary silently never happens, and what you get back instead is conversation cleared.

The same shape turns up wherever the string is assembled rather than typed: a tool result carrying text from a web page, a summary of an earlier session replayed into a new one, a document pasted in by a person who did not write it. In every case the library is being asked a question it has no way to answer: did a person type this, or did my program build it? From inside Chat, both are the same string.

Design A has to guess. Guessing wrong in one direction eats a legitimate message; guessing wrong in the other lets a document run a command. The usual patch is an escaping rule, which every caller must remember and one caller will forget.

Design B never asks the question, because the answer is implied by where the call sits. You hand RunCommand the line a person typed. A document containing /clear reaches Chat and only Chat, so it is data. That holds because of the shape of the code rather than because anyone remembered a rule, which is why it keeps holding after you have forgotten this page.

The cost is real and small: three lines of wiring in every application, and no commands at all if you skip them. That trade is revisited at the end.

What the parser actually does

The whole of it, in order:

  1. Trim the input. Leading and trailing whitespace goes, so a stray space before the slash is not fatal.
  2. Require a leading /. No prefix, not a command, and handled comes back false.
  3. Reject a bare slash. / on its own has no verb after it.
  4. Cut at the first space. What precedes it is the verb. What follows is the argument string, trimmed.
  5. Match the verb against each Command.Name, exactly. No case folding, no prefix matching, no aliases.

So /note check the invoice is the command note with the argument check the invoice. Everything after the first space is one string, handed over whole.

That last part is deliberate. The library does not tokenise arguments, split on quotes, or parse flags. It does not know whether your command wants a filename, a sentence or nothing, and guessing produces a syntax people have to learn for a feature that was supposed to save them typing. If your command needs structure, strings.Fields is right there, and so is flag.

An unrecognised verb goes to the model

If the verb matches nothing in your set, RunCommand reports handled false and your loop sends the line onward, exactly as typed. One run of it:

> /nope
Understood, let me know if you need help with anything else.

That answer is the model's. Nothing in the library produced it, which is why the wording is different every time you try it.

This follows from the same reasoning as everything above: the library cannot know that a line beginning with a slash was meant as a command. It might be a path, a regular expression, a fraction, or a date. Refusing anything it does not recognise would break all of those to catch a typo. So the default is to pass it through.

An application that would rather say "unknown command" can decide that for itself. CommandSet is a plain []Command with no methods hiding anything, so checking is a loop over it comparing Name.

The one shape that surprises people

RunCommand returns (CommandResult, bool, error), and the error is not always paired with handled being true. A command set that registers the same name twice, or one with an empty name, fails validation before dispatch, so it returns handled false and a non-nil error.

The obvious loop misses it:

res, handled, err := chat.RunCommand(ctx, client, commands, input)
if handled {
    // err is checked here...
}

reply, err := client.Chat(ctx, input) // ...and the validation error is gone.

Check err before you check handled if you want to catch it. In practice a duplicate name is a programming mistake that shows up on the first dispatch and never again, which is why the shape has survived, but it is a sharp edge and worth knowing about rather than discovering.

What a command is given

A command receives a CommandRequest with three fields, and that set is the whole of its power:

  • Args, the text after the verb.
  • Client, the live client, so a command can act on the conversation rather than just report on it.
  • Set, the set it belongs to, which is how /help describes commands it has never seen. It reads names and summaries off the set it was handed, so a command you wrote appears in help without registering it anywhere else.

Why editing the transcript needs the provider's help

Most built-ins only read. /usage asks the client what it has spent, /help reads the set. Two of them change the conversation, and those cannot be done from the core alone, because the core does not own the transcript. Each provider keeps history in whatever shape its SDK wants.

So the core asks. A provider that can rewrite its own history implements one method:

type TranscriptEditor interface {
    ApplyPolicyNow(ctx context.Context, policy HistoryPolicy) (removed int, err error)
}

One method, taking a policy, rather than a method per operation. /compact passes the configured history policy. /undo passes DropLastExchange, which the core writes. The provider does not know what undo is and does not need to, which means the next transcript command costs no provider change at all. A provider that cannot do this is not broken; it returns ErrCommandUnavailable and the command reports itself as unsupported.

There is a second error here worth expecting. A history policy may call the model (compaction summarises, which is a request), so the client cannot hold its lock while one runs. If another goroutine appends a turn during that window, the edit is refused with ErrTranscriptMoved rather than applied over the top of the arriving message. That is a retry, not a failure. It is also rare enough in a single-threaded read loop that you may never see it, and bad enough in a concurrent one that silently dropping someone's message would be unacceptable.

What this design gives up

Worth stating plainly, because the trade is real.

Every application wires it. Not many lines, but nobody gets them for free, and an application that forgets them has no commands and no warning that it is missing any.

There is no completion, no history and no aliases. Those belong to whatever is reading input, which the library has no view of, and a half-implementation that only worked on a plain terminal would be worse than none.

A person can invent an argument syntax per command. Nothing enforces consistency across a set, because nothing parses arguments.

All three are consequences of the library staying out of the input path, which is the same decision that keeps a retrieved document from clearing your history. It looked like the better half of the trade when the design was settled, and it still does.