Ask before running a tool
Let the model request a tool, but run it only after the user says yes.
package main
import (
"context"
"crypto/rand"
"encoding/json"
"log"
"net/http"
"os"
"time"
"github.com/digitallysavvy/go-ai/pkg/agent"
"github.com/digitallysavvy/go-ai/pkg/ai"
"github.com/digitallysavvy/go-ai/pkg/provider/types"
"github.com/digitallysavvy/go-ai/pkg/providers/anthropic"
"github.com/digitallysavvy/go-ai/pkg/schema"
)
func main() {
model, err := anthropic.New(anthropic.Config{APIKey: os.Getenv("ANTHROPIC_API_KEY")}).
LanguageModel(anthropic.ClaudeSonnet5_5)
if err != nil {
log.Fatal(err)
}
// Signs approval requests so a client cannot forge an approval. Use a
// stable secret in production, the same on every instance.
secret := []byte(os.Getenv("TOOL_APPROVAL_SECRET"))
if len(secret) == 0 {
secret = make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
log.Fatal(err)
}
}
deploy := types.Tool{
Name: "deploy",
Description: "Deploy the service to an environment. Requires user approval.",
Parameters: schema.NewSimpleJSONSchema(map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"env": map[string]interface{}{"type": "string", "enum": []string{"staging", "prod"}},
},
"required": []string{"env"},
}),
// The model can ask; only the user can say yes.
ToolApproval: true,
Execute: func(ctx context.Context, input map[string]interface{}, opts types.ToolExecutionOptions) (interface{}, error) {
return map[string]interface{}{"deployed": input["env"]}, nil
},
}
assistant := agent.NewToolLoopAgent(agent.AgentConfig{
Model: model,
System: "You deploy services. Call deploy when asked. Keep replies short.",
Tools: []types.Tool{deploy},
StopWhen: []ai.StopCondition{ai.IsStepCount(5)},
ExperimentalToolApprovalSecret: secret,
})
http.HandleFunc("POST /api/chat", func(w http.ResponseWriter, r *http.Request) {
var req struct {
Messages json.RawMessage `json:"messages"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<20)).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
chunks, _, err := agent.CreateAgentUIStreamFromUIMessages(r.Context(), assistant,
agent.CreateAgentUIStreamFromUIMessagesOptions{UIMessages: []byte(req.Messages)})
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
keepAlive := 15 * time.Second
if err := ai.PipeUIMessageChunksToResponse(chunks, w, &ai.UIMessageStreamResponseInit{KeepAliveMs: &keepAlive}); err != nil {
log.Printf("write stream: %v", err)
}
})
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Run it:
ANTHROPIC_API_KEY=... go run ./examples/recipes/tool-approval
Notes
ToolApproval: trueon the tool makes the agent stop and send an approval request instead of running it.ExperimentalToolApprovalSecretsigns each request. The server verifies the signature when the approval comes back, so a client cannot forge one.- The client needs
addToolApprovalResponseandsendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses. - To ask only sometimes, set
ToolApprovalto atypes.ToolNeedsApprovalFunc. Values the SDK doesn't recognize fail closed: the tool reports an error instead of running.
Go deeper
The full program is at examples/recipes/tool-approval/main.go.