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:
- Trim the input. Leading and trailing whitespace goes, so a stray space before the slash is not fatal.
- Require a leading
/. No prefix, not a command, andhandledcomes back false. - Reject a bare slash.
/on its own has no verb after it. - Cut at the first space. What precedes it is the verb. What follows is the argument string, trimmed.
- 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:
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/helpdescribes 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.
Related¶
- Add a slash command walks the wiring end to end.
- Let people run commands in a conversation is the task-shaped version, with every built-in listed.
- Errors covers
ErrCommandUnavailableandErrTranscriptMoved.