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
This commit is contained in:
Raymond Gasper 2026-06-08 12:18:06 -04:00
parent 2d46ef9b09
commit 5ffdafa5d8
6 changed files with 493 additions and 0 deletions

View file

@ -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

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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

View file

@ -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
}

View file

@ -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)),
))
}