Skip to content

Add a slash command

Chat applications tend to grow a few verbs that are not questions. Clear the conversation. Show what this has cost so far. Jot something down. Typing those as sentences and hoping the model understands does not work, because they are not jobs for the model at all. They are jobs for your program.

This tutorial builds that, one runnable piece at a time. You will finish with a chat loop where /help lists what is available, /clear starts over, and /note records something the model never sees.

Allow about twenty-five minutes. You run the program at the end of every step, so you will know straight away if something is wrong.

Before you start

You need:

  • Go 1.27 or newer.
  • A Gemini API key, exported as GEMINI_API_KEY. Gemini has a free tier that covers this comfortably. Any other provider works the same way; you would change only the blank import and the provider constant.
  • A new module to work in:
mkdir slash-tutorial && cd slash-tutorial
go mod init example.test/slash-tutorial
go get gitlab.com/phpboyscout/go/chat-gemini

Install the provider module and let it pull the core in with it. Asking for the core separately can select a version the provider was not built against.

If you have not used this library before, Getting started covers constructing a client and sending a message. This tutorial re-explains just enough to stand alone, so you can also start here and go back later.

Step 1: get a chat loop working

Start with no commands at all. A plain loop: read a line, send it, print the reply. Everything later is an addition to this.

Put this in main.go:

package main

import (
    "bufio"
    "context"
    "fmt"
    "os"
    "strings"

    "gitlab.com/phpboyscout/go/chat"
    _ "gitlab.com/phpboyscout/go/chat-gemini"
)

func main() {
    ctx := context.Background()

    client, err := chat.New(ctx, chat.Settings{Config: chat.Config{
        Provider:     chat.ProviderGemini,
        SystemPrompt: "You are a concise assistant. Answer in one sentence.",
    }})
    if err != nil {
        fmt.Fprintln(os.Stderr, "could not start:", err)
        os.Exit(1)
    }

    fmt.Println("Type a message, or ctrl-d to quit.")

    input := bufio.NewScanner(os.Stdin)

    for {
        fmt.Print("\n> ")

        if !input.Scan() {
            return
        }

        line := strings.TrimSpace(input.Text())
        if line == "" {
            continue
        }

        reply, err := client.Chat(ctx, line)
        if err != nil {
            fmt.Println("that failed:", err)
            continue
        }

        fmt.Println(reply)
    }
}

A few things in there are not obvious.

That underscore import is not decoration. _ "gitlab.com/phpboyscout/go/chat-gemini" pulls in the provider module purely for its registration side effect, the same trick database drivers use. Delete it and chat.New fails with unsupported provider: gemini.

The Settings wrapping Config looks fussy when Config is the only thing in it. Settings is the outer envelope, and it also carries optional things such as a logger, so it earns the extra brace later.

You may also have noticed there is no API key anywhere. Leave Token unset and the client reads GEMINI_API_KEY from the environment, which is why this file has no secret in it to leak.

Run it:

go run .
Type a message, or ctrl-d to quit.

> What is Go's zero value?
In Go, a "zero value" is the default value automatically assigned to a variable
upon declaration if no explicit initial value is provided.

Now try typing /help. You get a model reply, because as far as this program is concerned /help is just another message. When I ran it, the model cheerfully offered to assist with writing and coding, which is a perfectly good answer to a question nobody asked. That is the gap you are about to close.

Step 2: add the commands that come with the library

Three built-ins are enough to see the shape. Here is the whole file with them in, so you can see exactly where each piece sits. The three new parts are marked // NEW, and everything else is unchanged from step 1.

package main

import (
    "bufio"
    "context"
    "errors" // NEW
    "fmt"
    "os"
    "strings"

    "gitlab.com/phpboyscout/go/chat"
    _ "gitlab.com/phpboyscout/go/chat-gemini"
)

func main() {
    ctx := context.Background()

    client, err := chat.New(ctx, chat.Settings{Config: chat.Config{
        Provider:     chat.ProviderGemini,
        SystemPrompt: "You are a concise assistant. Answer in one sentence.",
    }})
    if err != nil {
        fmt.Fprintln(os.Stderr, "could not start:", err)
        os.Exit(1)
    }

    // NEW: the commands this program understands.
    commands := chat.CommandSet{
        chat.HelpCommand(),
        chat.UsageCommand(),
        chat.ClearCommand(),
    }

    fmt.Println("Type a message, /help for commands, or ctrl-d to quit.")

    input := bufio.NewScanner(os.Stdin)

    for {
        fmt.Print("\n> ")

        if !input.Scan() {
            return
        }

        line := strings.TrimSpace(input.Text())
        if line == "" {
            continue
        }

        // NEW: is this line a command? If so it has now run.
        result, handled, err := chat.RunCommand(ctx, client, commands, line)
        if handled {
            switch {
            case errors.Is(err, chat.ErrCommandUnavailable):
                fmt.Println("this provider cannot do that")
            case err != nil:
                fmt.Println("that command failed:", err)
            default:
                fmt.Println(result.Text)
            }

            continue
        }

        reply, err := client.Chat(ctx, line)
        if err != nil {
            fmt.Println("that failed:", err)
            continue
        }

        fmt.Println(reply)
    }
}

Run it and type /help:

Type a message, /help for commands, or ctrl-d to quit.

> /help
/clear      empty the conversation, keeping the system prompt
/help       list the available commands
/usage      show token usage and conversation length

What the three new parts do

The errors import is for the errors.Is check further down. Nothing more.

commands is an ordinary slice value. It is not registered with the client, and the client has no idea it exists. You could build two different sets and use one on Tuesdays. HelpCommand(), UsageCommand() and ClearCommand() are constructors that each return one chat.Command, so the set is just three values in a slice.

chat.RunCommand is the whole integration. It takes the line and the set, and answers one question: was that a command?

What happens when you type something

RunCommand returns three values, and handled is the one that decides what your loop does next. Follow a line through it.

You type /help:

  1. RunCommand trims the line and sees it starts with /.
  2. It splits at the first space: the name is help, the arguments are empty.
  3. It finds help in the set you passed.
  4. It runs that command, which builds the listing by reading the names and summaries out of your set.
  5. It returns handled as true, with the listing in result.Text.
  6. Your loop prints the text and continues.

No API call happened. /help costs nothing and works offline.

You type hello:

  1. RunCommand trims the line and sees it does not start with /.
  2. It stops there. No lookup, no set validation.
  3. It returns handled as false, with an empty result.
  4. Your loop skips the whole if and calls client.Chat(ctx, line).

The line reaches the model exactly as you typed it.

You type /nope: the same as /help until the lookup, which finds nothing. RunCommand returns handled as false, so your loop treats it as an ordinary message and sends it to the model. That surprises most people, and it is deliberate; step 4 covers why.

That is the entire mechanism. RunCommand never sees anything except the line you hand it, and Chat is never told any of this happened. The two paths meet nowhere except in your loop.

The error case that is not an error

The switch handles one thing worth knowing about now:

case errors.Is(err, chat.ErrCommandUnavailable):
    fmt.Println("this provider cannot do that")

Some commands need the provider's cooperation. /compact and /undo rewrite the conversation, and a provider that will not do that returns ErrCommandUnavailable. That is a fact about the provider rather than a bug, so it is worth saying plainly instead of printing it as a failure. None of the three commands you have added can raise it, but you will not have to come back and restructure the loop when you add one that can.

Step 3: write a command of your own

A Command is three things: a name, a one-line summary, and a function to run. Here is one that records a note. It never calls the model.

Add this above func main:

// notes is ordinary program state. The command below is the only thing that
// touches it, and the model never sees it.
var notes []string

// noteCommand records a line and returns straight away, without a model call.
func noteCommand() chat.Command {
    return chat.Command{
        Name:    "note",
        Summary: "jot something down without asking the model",
        Run: func(_ context.Context, req chat.CommandRequest) (chat.CommandResult, error) {
            if req.Args == "" {
                return chat.CommandResult{Text: "usage: /note <something to remember>"}, nil
            }

            notes = append(notes, req.Args)

            return chat.CommandResult{
                Text: fmt.Sprintf("noted, you have %d", len(notes)),
            }, nil
        },
    }
}

Then add one line to the set inside main, the one you built in step 2:

commands := chat.CommandSet{
    chat.HelpCommand(),
    chat.UsageCommand(),
    chat.ClearCommand(),
    noteCommand(), // NEW
}

That is all the registration there is. noteCommand() is now a value in the slice, so the lookup traced in step 2 can find it.

The Run signature is worth unpacking, because it is the only dense line here:

func(_ context.Context, req chat.CommandRequest) (chat.CommandResult, error)
  • The context is the one you passed to RunCommand. This command ignores it, because it does no I/O and cannot block. One that called the model would want it.
  • req carries what the command has to work with: req.Args, and also req.Client (the live client, if the command needs to act on the conversation) and req.Set (the whole set, which is how /help describes commands it has never seen).
  • CommandResult is what your loop prints. Text is the only field you need here.
  • The error is for a command that genuinely failed.

req.Args is everything after the verb, already trimmed. Type /note buy milk and Args is buy milk. The library does not split arguments further, parse flags, or handle quotes, so a command that wants structure can use strings.Fields and decide for itself.

Returning an empty Text is fine, and so is returning an error. An error means the command genuinely failed, not that the user typed it wrong. Your usage message above is an ordinary result, because being told the syntax is a normal outcome rather than a failure.

Run it:

> /help
/clear      empty the conversation, keeping the system prompt
/help       list the available commands
/note       jot something down without asking the model
/usage      show token usage and conversation length

> /note buy milk
noted, you have 1

> /note call the dentist
noted, you have 2

Your command is in /help, in alphabetical order, using the summary you wrote. You did not register it anywhere else. /help reads the set it was handed, and your command is in that set.

Step 4: what the code does not show you

Keep the program running and try these. Each one is easier to believe once you have watched it happen.

A turn is a message, not an exchange. Ask a question, then run /usage:

> What is Go's zero value?
In Go, a zero value is the default value automatically assigned to a variable
when it is declared without an explicit initial value.

> /usage
turns: 2
tokens: 21 in, 56 out (cumulative)
last request input tokens: 21

Two turns after one question. Yours and the model's are counted separately.

/clear does not reset the bill. Run /clear, then /usage again:

> /clear
conversation cleared

> /usage
turns: 0
tokens: 21 in, 56 out (cumulative)
last request input tokens: 21

The conversation is empty, and the token count is exactly where it was. This is deliberate. Clearing history changes what the model will see next; it does not un-spend money you have already spent.

An unknown command still goes to the model. Type something that looks like a command and is not:

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

That reply came from the model, not from the library. RunCommand did not recognise /nope, reported handled as false, and your loop sent it on. You will see different words, which is the clearest evidence of where they came from: the library has no wording to vary.

This is deliberate too. A line starting with a slash might be a file path, a regular expression, a date or a fraction, and refusing all of those to catch a typo would be a poor trade. If you would rather say "unknown command", you can: CommandSet is a plain slice, so loop over it and compare Name before you call Chat.

Matching is exact, so /NOTE is not /note. Try it:

> /NOTE buy milk
Hello, how can I assist you today?

Off to the model, because the lookup compares the verb to Name with no case folding, no prefix matching and no aliases. If you want /n as a shorthand for /note, add a second chat.Command with that name and the same Run, and it will show up in /help as its own entry. Nothing in the library does it for you, which is either honest or annoying depending on the day.

While you are here, run /note with nothing after it. req.Args is empty and your usage message comes back, which is the branch you wrote in step 3 and had not yet had a reason to trigger.

The complete program

If you assembled it differently and something is not working, compare against this. It is the program the transcripts above came from.

package main

import (
    "bufio"
    "context"
    "errors"
    "fmt"
    "os"
    "strings"

    "gitlab.com/phpboyscout/go/chat"
    _ "gitlab.com/phpboyscout/go/chat-gemini"
)

// notes is ordinary program state. The command below is the only thing that
// touches it, and the model never sees it.
var notes []string

// noteCommand records a line and returns straight away, without a model call.
func noteCommand() chat.Command {
    return chat.Command{
        Name:    "note",
        Summary: "jot something down without asking the model",
        Run: func(_ context.Context, req chat.CommandRequest) (chat.CommandResult, error) {
            if req.Args == "" {
                return chat.CommandResult{Text: "usage: /note <something to remember>"}, nil
            }

            notes = append(notes, req.Args)

            return chat.CommandResult{
                Text: fmt.Sprintf("noted, you have %d", len(notes)),
            }, nil
        },
    }
}

func main() {
    ctx := context.Background()

    client, err := chat.New(ctx, chat.Settings{Config: chat.Config{
        Provider:     chat.ProviderGemini,
        SystemPrompt: "You are a concise assistant. Answer in one sentence.",
    }})
    if err != nil {
        fmt.Fprintln(os.Stderr, "could not start:", err)
        os.Exit(1)
    }

    commands := chat.CommandSet{
        chat.HelpCommand(),
        chat.UsageCommand(),
        chat.ClearCommand(),
        noteCommand(),
    }

    fmt.Println("Type a message, /help for commands, or ctrl-d to quit.")

    input := bufio.NewScanner(os.Stdin)

    for {
        fmt.Print("\n> ")

        if !input.Scan() {
            return
        }

        line := strings.TrimSpace(input.Text())
        if line == "" {
            continue
        }

        result, handled, err := chat.RunCommand(ctx, client, commands, line)
        if handled {
            switch {
            case errors.Is(err, chat.ErrCommandUnavailable):
                fmt.Println("this provider cannot do that")
            case err != nil:
                fmt.Println("that command failed:", err)
            default:
                fmt.Println(result.Text)
            }

            continue
        }

        reply, err := client.Chat(ctx, line)
        if err != nil {
            fmt.Println("that failed:", err)
            continue
        }

        fmt.Println(reply)
    }
}

What you built

A chat loop where commands and questions travel the same path until you split them, at one line you wrote yourself.

The important part is where that split sits. Because you hand RunCommand the text a person typed, anything else your program assembles into a prompt cannot turn into a command. A retrieved document containing /clear is just words in a document, because it never reaches the parser. That holds because of where the call is, not because you remembered a rule.

Where to go next