Let people run commands in a conversation¶
If a person is typing into your application, they will eventually want to act on the conversation rather than continue it. Shorten it. Start again. Take back the last thing they said. This module ships a small vocabulary for that, and a registry so you can add your own.
Nothing happens unless you ask for it. There is no mode to switch on.
commands := chat.CommandSet{
chat.HelpCommand(),
chat.ClearCommand(),
chat.CompactCommand(),
chat.UndoCommand(),
chat.UsageCommand(),
chat.ToolsCommand(myTools),
}
for {
input := readLine()
if res, handled, err := chat.RunCommand(ctx, client, commands, input); handled {
if err != nil {
fmt.Println("command failed:", err)
continue
}
fmt.Println(res.Text)
continue
}
reply, err := client.Chat(ctx, input)
// ...
}
RunCommand reports handled false for anything that is not a registered
command, and leaves the input alone. So ordinary text, and text that merely
starts with a slash, goes to the model exactly as before.
The library never reads your prompts¶
This is worth being precise about, because it is the reason there is no escaping rule to learn.
Chat does not inspect what you pass it. Commands are parsed only by
RunCommand, and only from the string you hand it. A retrieved document
containing /clear, a tool result, a restored transcript: none of them can
become a command, because none of them goes through that call.
The cost is one if in your loop. What you get back is that you decide what
counts as a candidate. If your application assembles a prompt from a user's
message and three retrieved passages, hand RunCommand the user's message and
nothing else.
What ships in the box¶
| Command | Does | Needs |
|---|---|---|
/help |
lists every registered command, including yours | nothing |
/usage |
token usage and conversation length | nothing |
/tools |
the tools available to the model | you pass them in |
/clear |
empties the conversation | PersistentChatClient |
/compact |
applies the history policy now | TranscriptEditor |
/undo |
removes the most recent exchange | TranscriptEditor |
ToolsCommand takes the tools because SetTools is write-only: there is no way
to read a registered set back off a client. Pass the same slice you gave
SetTools, and rebuild the command if you replace them.
What /clear keeps¶
The system prompt and the model. A command changes what is in a conversation, never how the client is configured, so the person typing cannot delete your system instruction with a keystroke.
Turns you added with AddCached do go. AddCached appends a user turn, and a
clear that leaves user content behind is not a clear.
That costs less than it looks. Clearing stops referencing a cache rather than destroying one, so adding the same content again hits the live cache inside its time-to-live. You pay for the tokens, not for a fresh cache.
/compact and /undo need a provider that can edit its transcript¶
Only a provider can enumerate its own turns, so both commands need it to
implement chat.TranscriptEditor. The first-party adapters do, from
chat-anthropic, chat-openai and chat-gemini v0.11.0.
A provider without it returns chat.ErrCommandUnavailable, and /help still
lists the command with an explanation rather than hiding it. A verb that says why
it cannot run beats one that is silently missing, because the second reads as a
bug in your application.
claude-local will never implement it. Its conversation lives in the claude
CLI's own session, so it cannot enumerate turns at all.
One error that is not a failure¶
chat.ErrTranscriptMoved means the conversation changed while the command was
working, so the edit was computed against turns that no longer exist.
It happens because compaction calls the model, and the client cannot hold its lock across that round trip without serialising every other caller. If another goroutine appends during the window, applying the edit anyway would silently drop what arrived. So it is refused.
Ask again and it runs against the conversation as it now stands. Both built-in
commands already treat it that way and tell the person to retry, so you only need
to handle it if you call ApplyPolicyNow yourself.
Adding your own¶
A command is a name, a summary and a function:
deploy := chat.Command{
Name: "deploy",
Summary: "ship the current branch",
Run: func(ctx context.Context, req chat.CommandRequest) (chat.CommandResult, error) {
if err := shipIt(ctx, req.Args); err != nil {
return chat.CommandResult{}, err
}
return chat.CommandResult{Text: "deployed " + req.Args}, nil
},
}
req.Client is the client, req.Args is everything after the verb, and
req.Set is the whole vocabulary, which is how /help describes commands it
did not write.
Set Changed on the result when you altered the conversation, so a caller
watching a turn counter knows to refresh it.
Two commands with the same name is refused rather than resolved, because the alternative is you wondering which one ran.
One rule worth keeping¶
A command carries verbs, not settings. None of the built-ins selects a model, a prompt, a budget or an endpoint, and it is worth holding that line in the commands you add.
The person typing is not always the person who configured the client. In a multi-tenant deployment, a command that chose a model would let them redirect spend onto one you never picked, and possibly one they are not entitled to. They can ask that something happen. How it happens is yours.
Related¶
- Bound a long conversation covers the history
policy
/compactapplies, and how compaction differs from truncation. - Register a custom provider covers implementing
TranscriptEditorif you maintain a provider module. - Errors and sentinels lists
ErrCommandUnavailableandErrTranscriptMovedalongside the rest.