From 5ffdafa5d84e3836a788ed7578bbc1bc67238eb2 Mon Sep 17 00:00:00 2001 From: Raymond Gasper Date: Mon, 8 Jun 2026 12:18:06 -0400 Subject: [PATCH] docs+examples: spontaneous open_panel docs and approve/secret example extensions (#19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- examples/extensions/approve/README.md | 77 +++++++++ examples/extensions/approve/extension.json | 8 + examples/extensions/approve/main.go | 128 +++++++++++++++ examples/extensions/secret/README.md | 91 +++++++++++ examples/extensions/secret/extension.json | 8 + examples/extensions/secret/main.go | 181 +++++++++++++++++++++ 6 files changed, 493 insertions(+) create mode 100644 examples/extensions/approve/README.md create mode 100644 examples/extensions/approve/extension.json create mode 100644 examples/extensions/approve/main.go create mode 100644 examples/extensions/secret/README.md create mode 100644 examples/extensions/secret/extension.json create mode 100644 examples/extensions/secret/main.go diff --git a/examples/extensions/approve/README.md b/examples/extensions/approve/README.md new file mode 100644 index 0000000..91c5210 --- /dev/null +++ b/examples/extensions/approve/README.md @@ -0,0 +1,77 @@ +# approve — example zot extension (Go, phase 4) + +Demonstrates the **spontaneous `open_panel`** pattern introduced in +phase 4: an extension-registered tool opens a panel from inside its +handler goroutine, blocks until the user responds, then returns the +result to the model. + +## What it does + +Registers one LLM-callable tool: + +``` +approve_action(action, reason?) +``` + +When the model calls it, a panel appears in the TUI: + +``` +╭─ Approval required ─────────────────────╮ +│ Action: delete /tmp/build │ +│ Reason: cleaning up after the build │ +│ │ +│ y approve n / esc deny │ +╰──────────────────────────────────────────╯ + y approve n deny esc cancel +``` + +The model's tool call is held open until the user presses a key. The +model receives `"approved"` or `"denied: user rejected the action"` as +the tool result and replies accordingly. + +## Build + +```bash +cd examples/extensions/approve +go build -o approve . +``` + +## Install + +```bash +zot ext install . +``` + +## Try it + +In zot, ask: + +> Request approval to delete the temp directory. + +The model calls `approve_action`; the panel opens. Press **y** to +approve, **n** or **esc** to deny. + +> Ask me to approve before running any destructive command. + +The model will start calling `approve_action` before suggesting risky +steps; each call pauses until you respond. + +## Key points + +- The tool handler calls `e.OpenPanel(...)` directly — no slash command + needed. +- A `chan bool` bridges the panel key handler back to the blocked tool + goroutine; nothing in the wire protocol changes. +- Panel IDs are unique per call so multiple concurrent approvals don't + collide (rare in practice, but handled correctly). +- The `onClose` callback on `OnPanelKey` handles the case where the + user dismisses the panel from the TUI border rather than pressing a + key, treating it as a denial. + +## See also + +- `examples/extensions/secret` — same pattern with masked text input + (credential collection) +- `examples/extensions/guard` — blocks dangerous tool calls via + interception (no panel) +- `docs/extensions.md` — full protocol reference diff --git a/examples/extensions/approve/extension.json b/examples/extensions/approve/extension.json new file mode 100644 index 0000000..579adab --- /dev/null +++ b/examples/extensions/approve/extension.json @@ -0,0 +1,8 @@ +{ + "name": "approve", + "version": "1.0.0", + "exec": "./approve", + "language": "go", + "description": "human-in-the-loop approval gate: the model must get user sign-off before a tool runs", + "enabled": true +} diff --git a/examples/extensions/approve/main.go b/examples/extensions/approve/main.go new file mode 100644 index 0000000..9a908dd --- /dev/null +++ b/examples/extensions/approve/main.go @@ -0,0 +1,128 @@ +// approve — demonstrates the spontaneous open_panel pattern for +// human-in-the-loop tool approval gates. +// +// Registers one LLM-callable tool: +// +// approve_action(action: string, reason: string) +// +// When the model calls it, a panel opens asking the user to approve +// or deny. The tool goroutine blocks until the user responds; the +// model only sees the result after the user has acted. +// +// Build: +// +// cd examples/extensions/approve +// go build -o approve . +// +// Install: +// +// zot ext install . +// +// Try it — ask zot something like: +// +// "Request approval to delete the temp directory." +// +// The model will call approve_action; a panel appears in the TUI. Press +// y to approve or n (or esc) to deny. The model receives "approved" or +// "denied: user rejected the action" as the tool result and responds +// accordingly. +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "sync" + + "github.com/patriceckhart/zot/packages/agent/ext" +) + +const schema = `{ + "type": "object", + "properties": { + "action": { + "type": "string", + "description": "Short description of what is about to happen, shown to the user." + }, + "reason": { + "type": "string", + "description": "Why the action is needed, shown to the user." + } + }, + "required": ["action"] +}` + +func main() { + e := ext.New("approve", "1.0.0") + + // counter is used to generate unique panel IDs so that concurrent + // tool calls (unlikely in practice but possible) don't collide. + var mu sync.Mutex + var counter int + nextPID := func() string { + mu.Lock() + defer mu.Unlock() + counter++ + return fmt.Sprintf("approve-%d", counter) + } + + e.Tool("approve_action", "Ask the user to approve or deny an action before it proceeds.", json.RawMessage(schema), + func(args json.RawMessage) ext.ToolResult { + var in struct { + Action string `json:"action"` + Reason string `json:"reason"` + } + if err := json.Unmarshal(args, &in); err != nil { + return ext.TextErrorResult("invalid args: " + err.Error()) + } + if strings.TrimSpace(in.Action) == "" { + return ext.TextErrorResult("action is required") + } + + pid := nextPID() + decision := make(chan bool, 1) + + // Register key handler before opening the panel so there + // is no window where a key could arrive unhandled. + e.OnPanelKey(pid, func(key, text string) { + switch { + case key == "rune" && strings.ToLower(text) == "y": + e.ClosePanel(pid) + decision <- true + case key == "rune" && strings.ToLower(text) == "n", + key == "esc": + e.ClosePanel(pid) + decision <- false + } + }, func() { + // Host closed the panel (e.g. user navigated away). + select { + case decision <- false: + default: + } + }) + + lines := []string{ + " Action: " + in.Action, + } + if strings.TrimSpace(in.Reason) != "" { + lines = append(lines, " Reason: "+in.Reason) + } + lines = append(lines, "", " y approve n / esc deny") + + // Open the panel spontaneously from inside the tool handler. + e.OpenPanel(pid, "Approval required", lines, "y approve n deny esc cancel") + + // Block until the user responds. + if <-decision { + return ext.TextResult("approved") + } + return ext.TextErrorResult("denied: user rejected the action") + }) + + if err := e.Run(); err != nil { + e.Logf("fatal: %v", err) + os.Exit(1) + } +} diff --git a/examples/extensions/secret/README.md b/examples/extensions/secret/README.md new file mode 100644 index 0000000..f09478b --- /dev/null +++ b/examples/extensions/secret/README.md @@ -0,0 +1,91 @@ +# secret — example zot extension (Go, phase 4) + +Demonstrates **secret collection via a masked panel**: the model asks +for a resource that needs a credential, the extension collects the +credential directly from the user inside a panel, uses it to perform +the operation, and returns only the outcome to the model. The secret +is never written to any JSON frame or the transcript. + +## What it does + +Registers one LLM-callable tool: + +``` +fetch_with_password(url: string) +``` + +When the model calls it, a masked password panel opens: + +``` +╭─ Password required ─────────────────────╮ +│ URL: https://internal.example.com │ +│ │ +│ Password: ●●●●●●●▌ │ +╰──────────────────────────────────────────╯ + type password enter confirm esc cancel +``` + +The panel re-renders after every keystroke, showing bullets instead of +characters. Pressing Enter unblocks the tool goroutine, which uses the +password directly (in `doFetch`) and returns only the fetch outcome to +the model. + +## Security property + +The password lives only in the extension process's memory. It is never +serialised into a JSON frame, never appears in the transcript, and +never reaches the model. The model sees: + +``` +fetched https://internal.example.com successfully (password was 9 characters, not shown) +``` + +## Build + +```bash +cd examples/extensions/secret +go build -o secret . +``` + +## Install + +```bash +zot ext install . +``` + +## Try it + +In zot, ask: + +> Fetch https://internal.example.com/report — it needs a password. + +The model calls `fetch_with_password`; the masked panel opens. Type +anything and press **Enter**. The model receives the result; the +password is gone. + +## Adapting to real use + +Replace `doFetch` in `main.go` with a real HTTP request: + +```go +func doFetch(url, password string) ext.ToolResult { + req, _ := http.NewRequest("GET", url, nil) + req.SetBasicAuth("user", password) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return ext.TextErrorResult("fetch failed: " + err.Error()) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return ext.TextResult(string(body)) +} +``` + +The same pattern works for any credential type: API tokens, SSH +passphrases, TOTP codes, or freeform override strings. + +## See also + +- `examples/extensions/approve` — same pattern for approve/deny gates + (no text input, just y/n) +- `docs/extensions.md` — full protocol reference diff --git a/examples/extensions/secret/extension.json b/examples/extensions/secret/extension.json new file mode 100644 index 0000000..cd6bd1b --- /dev/null +++ b/examples/extensions/secret/extension.json @@ -0,0 +1,8 @@ +{ + "name": "secret", + "version": "1.0.0", + "exec": "./secret", + "language": "go", + "description": "collects secrets from the user via a masked panel; the model never sees the value", + "enabled": true +} diff --git a/examples/extensions/secret/main.go b/examples/extensions/secret/main.go new file mode 100644 index 0000000..3231f9a --- /dev/null +++ b/examples/extensions/secret/main.go @@ -0,0 +1,181 @@ +// secret — demonstrates collecting a secret from the user via a +// masked panel. The model never sees the value; it only sees the +// outcome (success or failure). +// +// Registers one LLM-callable tool: +// +// fetch_with_password(url: string) +// +// When the model calls it, a panel opens with a masked password +// field. The user types the password and presses Enter; the tool +// goroutine uses it directly to perform a (fake) authenticated fetch +// and returns only "fetched successfully" or an error to the model. +// The password exists only in the extension process's memory and is +// never written to any JSON frame or the transcript. +// +// Build: +// +// cd examples/extensions/secret +// go build -o secret . +// +// Install: +// +// zot ext install . +// +// Try it — ask zot something like: +// +// "Fetch https://internal.example.com/report — it needs a password." +// +// The model calls fetch_with_password; a masked password panel opens. +// Type anything and press Enter. The model receives the result without +// ever seeing what you typed. +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "sync" + + "github.com/patriceckhart/zot/packages/agent/ext" +) + +const schema = `{ + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The URL to fetch." + } + }, + "required": ["url"] +}` + +func main() { + e := ext.New("secret", "1.0.0") + + var mu sync.Mutex + var counter int + nextPID := func() string { + mu.Lock() + defer mu.Unlock() + counter++ + return fmt.Sprintf("secret-%d", counter) + } + + e.Tool("fetch_with_password", + "Fetch a URL that requires a password. The password is collected directly from the user and never exposed to the model.", + json.RawMessage(schema), + func(args json.RawMessage) ext.ToolResult { + var in struct { + URL string `json:"url"` + } + if err := json.Unmarshal(args, &in); err != nil { + return ext.TextErrorResult("invalid args: " + err.Error()) + } + if strings.TrimSpace(in.URL) == "" { + return ext.TextErrorResult("url is required") + } + + pid := nextPID() + + type result struct { + password string + ok bool + } + ch := make(chan result, 1) + + // input and inputMu are owned by the key handler goroutine. + var inputMu sync.Mutex + var input string + + render := func() { + inputMu.Lock() + masked := strings.Repeat("●", len([]rune(input))) + inputMu.Unlock() + e.RenderPanel(pid, "Password required", + []string{ + " URL: " + in.URL, + "", + " Password: " + masked + "▌", + }, + "type password enter confirm esc cancel") + } + + e.OnPanelKey(pid, func(key, text string) { + inputMu.Lock() + switch key { + case "rune": + input += text + inputMu.Unlock() + render() + return + case "backspace": + if len(input) > 0 { + r := []rune(input) + input = string(r[:len(r)-1]) + } + inputMu.Unlock() + render() + return + case "enter": + password := input + inputMu.Unlock() + e.ClosePanel(pid) + ch <- result{password: password, ok: true} + return + case "esc": + inputMu.Unlock() + e.ClosePanel(pid) + ch <- result{} + return + default: + inputMu.Unlock() + } + }, func() { + // Panel closed by host (user navigated away). + select { + case ch <- result{}: + default: + } + }) + + // Open the panel from inside the tool handler. + e.OpenPanel(pid, "Password required", + []string{ + " URL: " + in.URL, + "", + " Password: ▌", + }, + "type password enter confirm esc cancel") + + // Block until the user submits or cancels. + r := <-ch + if !r.ok { + return ext.TextErrorResult("cancelled: user did not provide a password") + } + + // Use the password directly here. It never leaves this + // process and is never written to any frame or transcript. + return doFetch(in.URL, r.password) + }) + + if err := e.Run(); err != nil { + e.Logf("fatal: %v", err) + os.Exit(1) + } +} + +// doFetch is a stub. Replace with a real http.Get + BasicAuth or +// token header in production use. +func doFetch(url, password string) ext.ToolResult { + if password == "" { + return ext.TextErrorResult("fetch failed: empty password") + } + // Demonstrate that we *have* the password without logging it. + return ext.TextResult(fmt.Sprintf( + "fetched %s successfully (password was %d characters, not shown)", + url, len([]rune(password)), + )) +}