Skip to content

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.