1// approve — demonstrates the spontaneous open_panel pattern for
2// human-in-the-loop tool approval gates.
3//
4// Registers one LLM-callable tool:
5//
6// approve_action(action: string, reason: string)
7//
8// When the model calls it, a panel opens asking the user to approve
9// or deny. The tool goroutine blocks until the user responds; the
10// model only sees the result after the user has acted.
11//
12// Build:
13//
14// cd examples/extensions/approve
15// go build -o approve .
16//
17// Install:
18//
19// zut ext install .
20//
21// Try it — ask zut something like:
22//
23// "Request approval to delete the temp directory."
24//
25// The model will call approve_action; a panel appears in the TUI. Press
26// y to approve or n (or esc) to deny. The model receives "approved" or
27// "denied: user rejected the action" as the tool result and responds
28// accordingly.
29package main
30
31import (
32 "encoding/json"
33 "fmt"
34 "os"
35 "strings"
36 "sync"
37
38 "github.com/bnema/zut/packages/agent/ext"
39)
40
41const schema = `{
42 "type": "object",
43 "properties": {
44 "action": {
45 "type": "string",
46 "description": "Short description of what is about to happen, shown to the user."
47 },
48 "reason": {
49 "type": "string",
50 "description": "Why the action is needed, shown to the user."
51 }
52 },
53 "required": ["action"]
54}`
55
56func main() {
57 e := ext.New("approve", "1.0.0")
58
59 // counter is used to generate unique panel IDs so that concurrent
60 // tool calls (unlikely in practice but possible) don't collide.
61 var mu sync.Mutex
62 var counter int
63 nextPID := func() string {
64 mu.Lock()
65 defer mu.Unlock()
66 counter++
67 return fmt.Sprintf("approve-%d", counter)
68 }
69
70 e.Tool("approve_action", "Ask the user to approve or deny an action before it proceeds.", json.RawMessage(schema),
71 func(args json.RawMessage) ext.ToolResult {
72 var in struct {
73 Action string `json:"action"`
74 Reason string `json:"reason"`
75 }
76 if err := json.Unmarshal(args, &in); err != nil {
77 return ext.TextErrorResult("invalid args: " + err.Error())
78 }
79 if strings.TrimSpace(in.Action) == "" {
80 return ext.TextErrorResult("action is required")
81 }
82
83 pid := nextPID()
84 decision := make(chan bool, 1)
85
86 buildLines := func(hint string) []string {
87 lines := []string{
88 " Action: " + in.Action,
89 }
90 if strings.TrimSpace(in.Reason) != "" {
91 lines = append(lines, " Reason: "+in.Reason)
92 }
93 lines = append(lines, "")
94 lines = append(lines, " "+hint)
95 return lines
96 }
97
98 const footer = "● this panel has focus — y approve n deny esc cancel"
99 const prompt = "› approve this action? [y/n]"
100 const badKey = "› unrecognised key — press y to approve or n to deny"
101
102 // Register key handler before opening the panel so there
103 // is no window where a key could arrive unhandled.
104 e.OnPanelKey(pid, func(key, text string) {
105 switch {
106 case key == "rune" && strings.ToLower(text) == "y":
107 e.ClosePanel(pid)
108 decision <- true
109 case key == "rune" && strings.ToLower(text) == "n",
110 key == "esc":
111 e.ClosePanel(pid)
112 decision <- false
113 default:
114 // Unknown key: re-render with a nudge so the user
115 // knows the panel is alive and what to press.
116 e.RenderPanel(pid, "Approval required",
117 buildLines(badKey), footer)
118 }
119 }, func() {
120 // Host closed the panel (e.g. user navigated away).
121 select {
122 case decision <- false:
123 default:
124 }
125 })
126
127 // Open the panel spontaneously from inside the tool handler.
128 e.OpenPanel(pid, "Approval required", buildLines(prompt), footer)
129
130 // Block until the user responds.
131 if <-decision {
132 return ext.TextResult("approved")
133 }
134 return ext.TextErrorResult("denied: user rejected the action")
135 })
136
137 if err := e.Run(); err != nil {
138 e.Logf("fatal: %v", err)
139 os.Exit(1)
140 }
141}