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:
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:
RunCommandtrims the line and sees it starts with/.- It splits at the first space: the name is
help, the arguments are empty. - It finds
helpin the set you passed. - It runs that command, which builds the listing by reading the names and summaries out of your set.
- It returns
handledas true, with the listing inresult.Text. - Your loop prints the text and
continues.
No API call happened. /help costs nothing and works offline.
You type hello:
RunCommandtrims the line and sees it does not start with/.- It stops there. No lookup, no set validation.
- It returns
handledas false, with an empty result. - Your loop skips the whole
ifand callsclient.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:
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:
- 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. reqcarries what the command has to work with:req.Args, and alsoreq.Client(the live client, if the command needs to act on the conversation) andreq.Set(the whole set, which is how/helpdescribes commands it has never seen).CommandResultis what your loop prints.Textis 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:
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:
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¶
- How a slash command is parsed: the parser step by step, and why dispatch is yours to call.
- Let people run commands in a conversation:
every built-in, including
/tools,/compactand/undo, which need a little more setup than the three used here. - Bound a long conversation: history
policies, which is what
/compactapplies.