detached 5ffdafa

docs+examples: spontaneous open_panel docs and approve/secret example extensions (#19)

- docs/extensions.md: add open_panel spontaneous frame section with
  blocking tool pattern explanation, concurrent-panel note, and
  references to new examples; add approve/secret to See also list;
  add roadmap entry
- examples/extensions/approve/: approve_action tool — opens a y/n
  panel from inside the tool handler, blocks until user responds
- examples/extensions/secret/: fetch_with_password tool — masked
  password input panel, secret never leaves the extension process
commit
5ffdafa5d84e3836a788ed7578bbc1bc67238eb2
author
Raymond Gasper · · 2026-06-08 16:18 UTC
parent
2d46ef9
signature
unsigned
6 files · +493 -0
A examples/extensions/approve/README.md +77 -0
@@ -0,0 +1,77 @@
1+# approve — example zot extension (Go, phase 4)
2+
3+Demonstrates the **spontaneous `open_panel`** pattern introduced in
4+phase 4: an extension-registered tool opens a panel from inside its
5+handler goroutine, blocks until the user responds, then returns the
6+result to the model.
7+
8+## What it does
9+
10+Registers one LLM-callable tool:
11+
12+```
13+approve_action(action, reason?)
14+```
15+
16+When the model calls it, a panel appears in the TUI:
17+
18+```
19+╭─ Approval required ─────────────────────╮
20+│ Action: delete /tmp/build │
21+│ Reason: cleaning up after the build │
22+│ │
23+│ y approve n / esc deny │
24+╰──────────────────────────────────────────╯
25+ y approve n deny esc cancel
26+```
27+
28+The model's tool call is held open until the user presses a key. The
29+model receives `"approved"` or `"denied: user rejected the action"` as
30+the tool result and replies accordingly.
31+
32+## Build
33+
34+```bash
35+cd examples/extensions/approve
36+go build -o approve .
37+```
38+
39+## Install
40+
41+```bash
42+zot ext install .
43+```
44+
45+## Try it
46+
47+In zot, ask:
48+
49+> Request approval to delete the temp directory.
50+
51+The model calls `approve_action`; the panel opens. Press **y** to
52+approve, **n** or **esc** to deny.
53+
54+> Ask me to approve before running any destructive command.
55+
56+The model will start calling `approve_action` before suggesting risky
57+steps; each call pauses until you respond.
58+
59+## Key points
60+
61+- The tool handler calls `e.OpenPanel(...)` directly — no slash command
62+ needed.
63+- A `chan bool` bridges the panel key handler back to the blocked tool
64+ goroutine; nothing in the wire protocol changes.
65+- Panel IDs are unique per call so multiple concurrent approvals don't
66+ collide (rare in practice, but handled correctly).
67+- The `onClose` callback on `OnPanelKey` handles the case where the
68+ user dismisses the panel from the TUI border rather than pressing a
69+ key, treating it as a denial.
70+
71+## See also
72+
73+- `examples/extensions/secret` — same pattern with masked text input
74+ (credential collection)
75+- `examples/extensions/guard` — blocks dangerous tool calls via
76+ interception (no panel)
77+- `docs/extensions.md` — full protocol reference
@@ -0,0 +1,77 @@
1+# approve — example zot extension (Go, phase 4)
2+
3+Demonstrates the **spontaneous `open_panel`** pattern introduced in
4+phase 4: an extension-registered tool opens a panel from inside its
5+handler goroutine, blocks until the user responds, then returns the
6+result to the model.
7+
8+## What it does
9+
10+Registers one LLM-callable tool:
11+
12+```
13+approve_action(action, reason?)
14+```
15+
16+When the model calls it, a panel appears in the TUI:
17+
18+```
19+╭─ Approval required ─────────────────────╮
20+│ Action: delete /tmp/build │
21+│ Reason: cleaning up after the build │
22+│ │
23+│ y approve n / esc deny │
24+╰──────────────────────────────────────────╯
25+ y approve n deny esc cancel
26+```
27+
28+The model's tool call is held open until the user presses a key. The
29+model receives `"approved"` or `"denied: user rejected the action"` as
30+the tool result and replies accordingly.
31+
32+## Build
33+
34+```bash
35+cd examples/extensions/approve
36+go build -o approve .
37+```
38+
39+## Install
40+
41+```bash
42+zot ext install .
43+```
44+
45+## Try it
46+
47+In zot, ask:
48+
49+> Request approval to delete the temp directory.
50+
51+The model calls `approve_action`; the panel opens. Press **y** to
52+approve, **n** or **esc** to deny.
53+
54+> Ask me to approve before running any destructive command.
55+
56+The model will start calling `approve_action` before suggesting risky
57+steps; each call pauses until you respond.
58+
59+## Key points
60+
61+- The tool handler calls `e.OpenPanel(...)` directly — no slash command
62+ needed.
63+- A `chan bool` bridges the panel key handler back to the blocked tool
64+ goroutine; nothing in the wire protocol changes.
65+- Panel IDs are unique per call so multiple concurrent approvals don't
66+ collide (rare in practice, but handled correctly).
67+- The `onClose` callback on `OnPanelKey` handles the case where the
68+ user dismisses the panel from the TUI border rather than pressing a
69+ key, treating it as a denial.
70+
71+## See also
72+
73+- `examples/extensions/secret` — same pattern with masked text input
74+ (credential collection)
75+- `examples/extensions/guard` — blocks dangerous tool calls via
76+ interception (no panel)
77+- `docs/extensions.md` — full protocol reference
A examples/extensions/approve/extension.json +8 -0
@@ -0,0 +1,8 @@
1+{
2+ "name": "approve",
3+ "version": "1.0.0",
4+ "exec": "./approve",
5+ "language": "go",
6+ "description": "human-in-the-loop approval gate: the model must get user sign-off before a tool runs",
7+ "enabled": true
8+}
@@ -0,0 +1,8 @@
1+{
2+ "name": "approve",
3+ "version": "1.0.0",
4+ "exec": "./approve",
5+ "language": "go",
6+ "description": "human-in-the-loop approval gate: the model must get user sign-off before a tool runs",
7+ "enabled": true
8+}
A examples/extensions/approve/main.go +128 -0
@@ -0,0 +1,128 @@
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+// zot ext install .
20+//
21+// Try it — ask zot 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.
29+package main
30+
31+import (
32+ "encoding/json"
33+ "fmt"
34+ "os"
35+ "strings"
36+ "sync"
37+
38+ "github.com/patriceckhart/zot/packages/agent/ext"
39+)
40+
41+const 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+
56+func 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+ // Register key handler before opening the panel so there
87+ // is no window where a key could arrive unhandled.
88+ e.OnPanelKey(pid, func(key, text string) {
89+ switch {
90+ case key == "rune" && strings.ToLower(text) == "y":
91+ e.ClosePanel(pid)
92+ decision <- true
93+ case key == "rune" && strings.ToLower(text) == "n",
94+ key == "esc":
95+ e.ClosePanel(pid)
96+ decision <- false
97+ }
98+ }, func() {
99+ // Host closed the panel (e.g. user navigated away).
100+ select {
101+ case decision <- false:
102+ default:
103+ }
104+ })
105+
106+ lines := []string{
107+ " Action: " + in.Action,
108+ }
109+ if strings.TrimSpace(in.Reason) != "" {
110+ lines = append(lines, " Reason: "+in.Reason)
111+ }
112+ lines = append(lines, "", " y approve n / esc deny")
113+
114+ // Open the panel spontaneously from inside the tool handler.
115+ e.OpenPanel(pid, "Approval required", lines, "y approve n deny esc cancel")
116+
117+ // Block until the user responds.
118+ if <-decision {
119+ return ext.TextResult("approved")
120+ }
121+ return ext.TextErrorResult("denied: user rejected the action")
122+ })
123+
124+ if err := e.Run(); err != nil {
125+ e.Logf("fatal: %v", err)
126+ os.Exit(1)
127+ }
128+}
@@ -0,0 +1,128 @@
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+// zot ext install .
20+//
21+// Try it — ask zot 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.
29+package main
30+
31+import (
32+ "encoding/json"
33+ "fmt"
34+ "os"
35+ "strings"
36+ "sync"
37+
38+ "github.com/patriceckhart/zot/packages/agent/ext"
39+)
40+
41+const 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+
56+func 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+ // Register key handler before opening the panel so there
87+ // is no window where a key could arrive unhandled.
88+ e.OnPanelKey(pid, func(key, text string) {
89+ switch {
90+ case key == "rune" && strings.ToLower(text) == "y":
91+ e.ClosePanel(pid)
92+ decision <- true
93+ case key == "rune" && strings.ToLower(text) == "n",
94+ key == "esc":
95+ e.ClosePanel(pid)
96+ decision <- false
97+ }
98+ }, func() {
99+ // Host closed the panel (e.g. user navigated away).
100+ select {
101+ case decision <- false:
102+ default:
103+ }
104+ })
105+
106+ lines := []string{
107+ " Action: " + in.Action,
108+ }
109+ if strings.TrimSpace(in.Reason) != "" {
110+ lines = append(lines, " Reason: "+in.Reason)
111+ }
112+ lines = append(lines, "", " y approve n / esc deny")
113+
114+ // Open the panel spontaneously from inside the tool handler.
115+ e.OpenPanel(pid, "Approval required", lines, "y approve n deny esc cancel")
116+
117+ // Block until the user responds.
118+ if <-decision {
119+ return ext.TextResult("approved")
120+ }
121+ return ext.TextErrorResult("denied: user rejected the action")
122+ })
123+
124+ if err := e.Run(); err != nil {
125+ e.Logf("fatal: %v", err)
126+ os.Exit(1)
127+ }
128+}
A examples/extensions/secret/README.md +91 -0
@@ -0,0 +1,91 @@
1+# secret — example zot extension (Go, phase 4)
2+
3+Demonstrates **secret collection via a masked panel**: the model asks
4+for a resource that needs a credential, the extension collects the
5+credential directly from the user inside a panel, uses it to perform
6+the operation, and returns only the outcome to the model. The secret
7+is never written to any JSON frame or the transcript.
8+
9+## What it does
10+
11+Registers one LLM-callable tool:
12+
13+```
14+fetch_with_password(url: string)
15+```
16+
17+When the model calls it, a masked password panel opens:
18+
19+```
20+╭─ Password required ─────────────────────╮
21+│ URL: https://internal.example.com │
22+│ │
23+│ Password: ●●●●●●●▌ │
24+╰──────────────────────────────────────────╯
25+ type password enter confirm esc cancel
26+```
27+
28+The panel re-renders after every keystroke, showing bullets instead of
29+characters. Pressing Enter unblocks the tool goroutine, which uses the
30+password directly (in `doFetch`) and returns only the fetch outcome to
31+the model.
32+
33+## Security property
34+
35+The password lives only in the extension process's memory. It is never
36+serialised into a JSON frame, never appears in the transcript, and
37+never reaches the model. The model sees:
38+
39+```
40+fetched https://internal.example.com successfully (password was 9 characters, not shown)
41+```
42+
43+## Build
44+
45+```bash
46+cd examples/extensions/secret
47+go build -o secret .
48+```
49+
50+## Install
51+
52+```bash
53+zot ext install .
54+```
55+
56+## Try it
57+
58+In zot, ask:
59+
60+> Fetch https://internal.example.com/report — it needs a password.
61+
62+The model calls `fetch_with_password`; the masked panel opens. Type
63+anything and press **Enter**. The model receives the result; the
64+password is gone.
65+
66+## Adapting to real use
67+
68+Replace `doFetch` in `main.go` with a real HTTP request:
69+
70+```go
71+func doFetch(url, password string) ext.ToolResult {
72+ req, _ := http.NewRequest("GET", url, nil)
73+ req.SetBasicAuth("user", password)
74+ resp, err := http.DefaultClient.Do(req)
75+ if err != nil {
76+ return ext.TextErrorResult("fetch failed: " + err.Error())
77+ }
78+ defer resp.Body.Close()
79+ body, _ := io.ReadAll(resp.Body)
80+ return ext.TextResult(string(body))
81+}
82+```
83+
84+The same pattern works for any credential type: API tokens, SSH
85+passphrases, TOTP codes, or freeform override strings.
86+
87+## See also
88+
89+- `examples/extensions/approve` — same pattern for approve/deny gates
90+ (no text input, just y/n)
91+- `docs/extensions.md` — full protocol reference
@@ -0,0 +1,91 @@
1+# secret — example zot extension (Go, phase 4)
2+
3+Demonstrates **secret collection via a masked panel**: the model asks
4+for a resource that needs a credential, the extension collects the
5+credential directly from the user inside a panel, uses it to perform
6+the operation, and returns only the outcome to the model. The secret
7+is never written to any JSON frame or the transcript.
8+
9+## What it does
10+
11+Registers one LLM-callable tool:
12+
13+```
14+fetch_with_password(url: string)
15+```
16+
17+When the model calls it, a masked password panel opens:
18+
19+```
20+╭─ Password required ─────────────────────╮
21+│ URL: https://internal.example.com │
22+│ │
23+│ Password: ●●●●●●●▌ │
24+╰──────────────────────────────────────────╯
25+ type password enter confirm esc cancel
26+```
27+
28+The panel re-renders after every keystroke, showing bullets instead of
29+characters. Pressing Enter unblocks the tool goroutine, which uses the
30+password directly (in `doFetch`) and returns only the fetch outcome to
31+the model.
32+
33+## Security property
34+
35+The password lives only in the extension process's memory. It is never
36+serialised into a JSON frame, never appears in the transcript, and
37+never reaches the model. The model sees:
38+
39+```
40+fetched https://internal.example.com successfully (password was 9 characters, not shown)
41+```
42+
43+## Build
44+
45+```bash
46+cd examples/extensions/secret
47+go build -o secret .
48+```
49+
50+## Install
51+
52+```bash
53+zot ext install .
54+```
55+
56+## Try it
57+
58+In zot, ask:
59+
60+> Fetch https://internal.example.com/report — it needs a password.
61+
62+The model calls `fetch_with_password`; the masked panel opens. Type
63+anything and press **Enter**. The model receives the result; the
64+password is gone.
65+
66+## Adapting to real use
67+
68+Replace `doFetch` in `main.go` with a real HTTP request:
69+
70+```go
71+func doFetch(url, password string) ext.ToolResult {
72+ req, _ := http.NewRequest("GET", url, nil)
73+ req.SetBasicAuth("user", password)
74+ resp, err := http.DefaultClient.Do(req)
75+ if err != nil {
76+ return ext.TextErrorResult("fetch failed: " + err.Error())
77+ }
78+ defer resp.Body.Close()
79+ body, _ := io.ReadAll(resp.Body)
80+ return ext.TextResult(string(body))
81+}
82+```
83+
84+The same pattern works for any credential type: API tokens, SSH
85+passphrases, TOTP codes, or freeform override strings.
86+
87+## See also
88+
89+- `examples/extensions/approve` — same pattern for approve/deny gates
90+ (no text input, just y/n)
91+- `docs/extensions.md` — full protocol reference
A examples/extensions/secret/extension.json +8 -0
@@ -0,0 +1,8 @@
1+{
2+ "name": "secret",
3+ "version": "1.0.0",
4+ "exec": "./secret",
5+ "language": "go",
6+ "description": "collects secrets from the user via a masked panel; the model never sees the value",
7+ "enabled": true
8+}
@@ -0,0 +1,8 @@
1+{
2+ "name": "secret",
3+ "version": "1.0.0",
4+ "exec": "./secret",
5+ "language": "go",
6+ "description": "collects secrets from the user via a masked panel; the model never sees the value",
7+ "enabled": true
8+}
A examples/extensions/secret/main.go +181 -0
@@ -0,0 +1,181 @@
1+// secret — demonstrates collecting a secret from the user via a
2+// masked panel. The model never sees the value; it only sees the
3+// outcome (success or failure).
4+//
5+// Registers one LLM-callable tool:
6+//
7+// fetch_with_password(url: string)
8+//
9+// When the model calls it, a panel opens with a masked password
10+// field. The user types the password and presses Enter; the tool
11+// goroutine uses it directly to perform a (fake) authenticated fetch
12+// and returns only "fetched successfully" or an error to the model.
13+// The password exists only in the extension process's memory and is
14+// never written to any JSON frame or the transcript.
15+//
16+// Build:
17+//
18+// cd examples/extensions/secret
19+// go build -o secret .
20+//
21+// Install:
22+//
23+// zot ext install .
24+//
25+// Try it — ask zot something like:
26+//
27+// "Fetch https://internal.example.com/report — it needs a password."
28+//
29+// The model calls fetch_with_password; a masked password panel opens.
30+// Type anything and press Enter. The model receives the result without
31+// ever seeing what you typed.
32+package main
33+
34+import (
35+ "encoding/json"
36+ "fmt"
37+ "os"
38+ "strings"
39+ "sync"
40+
41+ "github.com/patriceckhart/zot/packages/agent/ext"
42+)
43+
44+const schema = `{
45+ "type": "object",
46+ "properties": {
47+ "url": {
48+ "type": "string",
49+ "description": "The URL to fetch."
50+ }
51+ },
52+ "required": ["url"]
53+}`
54+
55+func main() {
56+ e := ext.New("secret", "1.0.0")
57+
58+ var mu sync.Mutex
59+ var counter int
60+ nextPID := func() string {
61+ mu.Lock()
62+ defer mu.Unlock()
63+ counter++
64+ return fmt.Sprintf("secret-%d", counter)
65+ }
66+
67+ e.Tool("fetch_with_password",
68+ "Fetch a URL that requires a password. The password is collected directly from the user and never exposed to the model.",
69+ json.RawMessage(schema),
70+ func(args json.RawMessage) ext.ToolResult {
71+ var in struct {
72+ URL string `json:"url"`
73+ }
74+ if err := json.Unmarshal(args, &in); err != nil {
75+ return ext.TextErrorResult("invalid args: " + err.Error())
76+ }
77+ if strings.TrimSpace(in.URL) == "" {
78+ return ext.TextErrorResult("url is required")
79+ }
80+
81+ pid := nextPID()
82+
83+ type result struct {
84+ password string
85+ ok bool
86+ }
87+ ch := make(chan result, 1)
88+
89+ // input and inputMu are owned by the key handler goroutine.
90+ var inputMu sync.Mutex
91+ var input string
92+
93+ render := func() {
94+ inputMu.Lock()
95+ masked := strings.Repeat("●", len([]rune(input)))
96+ inputMu.Unlock()
97+ e.RenderPanel(pid, "Password required",
98+ []string{
99+ " URL: " + in.URL,
100+ "",
101+ " Password: " + masked + "▌",
102+ },
103+ "type password enter confirm esc cancel")
104+ }
105+
106+ e.OnPanelKey(pid, func(key, text string) {
107+ inputMu.Lock()
108+ switch key {
109+ case "rune":
110+ input += text
111+ inputMu.Unlock()
112+ render()
113+ return
114+ case "backspace":
115+ if len(input) > 0 {
116+ r := []rune(input)
117+ input = string(r[:len(r)-1])
118+ }
119+ inputMu.Unlock()
120+ render()
121+ return
122+ case "enter":
123+ password := input
124+ inputMu.Unlock()
125+ e.ClosePanel(pid)
126+ ch <- result{password: password, ok: true}
127+ return
128+ case "esc":
129+ inputMu.Unlock()
130+ e.ClosePanel(pid)
131+ ch <- result{}
132+ return
133+ default:
134+ inputMu.Unlock()
135+ }
136+ }, func() {
137+ // Panel closed by host (user navigated away).
138+ select {
139+ case ch <- result{}:
140+ default:
141+ }
142+ })
143+
144+ // Open the panel from inside the tool handler.
145+ e.OpenPanel(pid, "Password required",
146+ []string{
147+ " URL: " + in.URL,
148+ "",
149+ " Password: ▌",
150+ },
151+ "type password enter confirm esc cancel")
152+
153+ // Block until the user submits or cancels.
154+ r := <-ch
155+ if !r.ok {
156+ return ext.TextErrorResult("cancelled: user did not provide a password")
157+ }
158+
159+ // Use the password directly here. It never leaves this
160+ // process and is never written to any frame or transcript.
161+ return doFetch(in.URL, r.password)
162+ })
163+
164+ if err := e.Run(); err != nil {
165+ e.Logf("fatal: %v", err)
166+ os.Exit(1)
167+ }
168+}
169+
170+// doFetch is a stub. Replace with a real http.Get + BasicAuth or
171+// token header in production use.
172+func doFetch(url, password string) ext.ToolResult {
173+ if password == "" {
174+ return ext.TextErrorResult("fetch failed: empty password")
175+ }
176+ // Demonstrate that we *have* the password without logging it.
177+ return ext.TextResult(fmt.Sprintf(
178+ "fetched %s successfully (password was %d characters, not shown)",
179+ url, len([]rune(password)),
180+ ))
181+}
@@ -0,0 +1,181 @@
1+// secret — demonstrates collecting a secret from the user via a
2+// masked panel. The model never sees the value; it only sees the
3+// outcome (success or failure).
4+//
5+// Registers one LLM-callable tool:
6+//
7+// fetch_with_password(url: string)
8+//
9+// When the model calls it, a panel opens with a masked password
10+// field. The user types the password and presses Enter; the tool
11+// goroutine uses it directly to perform a (fake) authenticated fetch
12+// and returns only "fetched successfully" or an error to the model.
13+// The password exists only in the extension process's memory and is
14+// never written to any JSON frame or the transcript.
15+//
16+// Build:
17+//
18+// cd examples/extensions/secret
19+// go build -o secret .
20+//
21+// Install:
22+//
23+// zot ext install .
24+//
25+// Try it — ask zot something like:
26+//
27+// "Fetch https://internal.example.com/report — it needs a password."
28+//
29+// The model calls fetch_with_password; a masked password panel opens.
30+// Type anything and press Enter. The model receives the result without
31+// ever seeing what you typed.
32+package main
33+
34+import (
35+ "encoding/json"
36+ "fmt"
37+ "os"
38+ "strings"
39+ "sync"
40+
41+ "github.com/patriceckhart/zot/packages/agent/ext"
42+)
43+
44+const schema = `{
45+ "type": "object",
46+ "properties": {
47+ "url": {
48+ "type": "string",
49+ "description": "The URL to fetch."
50+ }
51+ },
52+ "required": ["url"]
53+}`
54+
55+func main() {
56+ e := ext.New("secret", "1.0.0")
57+
58+ var mu sync.Mutex
59+ var counter int
60+ nextPID := func() string {
61+ mu.Lock()
62+ defer mu.Unlock()
63+ counter++
64+ return fmt.Sprintf("secret-%d", counter)
65+ }
66+
67+ e.Tool("fetch_with_password",
68+ "Fetch a URL that requires a password. The password is collected directly from the user and never exposed to the model.",
69+ json.RawMessage(schema),
70+ func(args json.RawMessage) ext.ToolResult {
71+ var in struct {
72+ URL string `json:"url"`
73+ }
74+ if err := json.Unmarshal(args, &in); err != nil {
75+ return ext.TextErrorResult("invalid args: " + err.Error())
76+ }
77+ if strings.TrimSpace(in.URL) == "" {
78+ return ext.TextErrorResult("url is required")
79+ }
80+
81+ pid := nextPID()
82+
83+ type result struct {
84+ password string
85+ ok bool
86+ }
87+ ch := make(chan result, 1)
88+
89+ // input and inputMu are owned by the key handler goroutine.
90+ var inputMu sync.Mutex
91+ var input string
92+
93+ render := func() {
94+ inputMu.Lock()
95+ masked := strings.Repeat("●", len([]rune(input)))
96+ inputMu.Unlock()
97+ e.RenderPanel(pid, "Password required",
98+ []string{
99+ " URL: " + in.URL,
100+ "",
101+ " Password: " + masked + "▌",
102+ },
103+ "type password enter confirm esc cancel")
104+ }
105+
106+ e.OnPanelKey(pid, func(key, text string) {
107+ inputMu.Lock()
108+ switch key {
109+ case "rune":
110+ input += text
111+ inputMu.Unlock()
112+ render()
113+ return
114+ case "backspace":
115+ if len(input) > 0 {
116+ r := []rune(input)
117+ input = string(r[:len(r)-1])
118+ }
119+ inputMu.Unlock()
120+ render()
121+ return
122+ case "enter":
123+ password := input
124+ inputMu.Unlock()
125+ e.ClosePanel(pid)
126+ ch <- result{password: password, ok: true}
127+ return
128+ case "esc":
129+ inputMu.Unlock()
130+ e.ClosePanel(pid)
131+ ch <- result{}
132+ return
133+ default:
134+ inputMu.Unlock()
135+ }
136+ }, func() {
137+ // Panel closed by host (user navigated away).
138+ select {
139+ case ch <- result{}:
140+ default:
141+ }
142+ })
143+
144+ // Open the panel from inside the tool handler.
145+ e.OpenPanel(pid, "Password required",
146+ []string{
147+ " URL: " + in.URL,
148+ "",
149+ " Password: ▌",
150+ },
151+ "type password enter confirm esc cancel")
152+
153+ // Block until the user submits or cancels.
154+ r := <-ch
155+ if !r.ok {
156+ return ext.TextErrorResult("cancelled: user did not provide a password")
157+ }
158+
159+ // Use the password directly here. It never leaves this
160+ // process and is never written to any frame or transcript.
161+ return doFetch(in.URL, r.password)
162+ })
163+
164+ if err := e.Run(); err != nil {
165+ e.Logf("fatal: %v", err)
166+ os.Exit(1)
167+ }
168+}
169+
170+// doFetch is a stub. Replace with a real http.Get + BasicAuth or
171+// token header in production use.
172+func doFetch(url, password string) ext.ToolResult {
173+ if password == "" {
174+ return ext.TextErrorResult("fetch failed: empty password")
175+ }
176+ // Demonstrate that we *have* the password without logging it.
177+ return ext.TextResult(fmt.Sprintf(
178+ "fetched %s successfully (password was %d characters, not shown)",
179+ url, len([]rune(password)),
180+ ))
181+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close