mirror of
https://github.com/patriceckhart/zot.git
synced 2026-07-06 01:51:04 +02:00
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:
parent
2d46ef9b09
commit
5ffdafa5d8
6 changed files with 493 additions and 0 deletions
77
examples/extensions/approve/README.md
Normal file
77
examples/extensions/approve/README.md
Normal 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
|
||||
8
examples/extensions/approve/extension.json
Normal file
8
examples/extensions/approve/extension.json
Normal 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
|
||||
}
|
||||
128
examples/extensions/approve/main.go
Normal file
128
examples/extensions/approve/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
91
examples/extensions/secret/README.md
Normal file
91
examples/extensions/secret/README.md
Normal 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
|
||||
8
examples/extensions/secret/extension.json
Normal file
8
examples/extensions/secret/extension.json
Normal 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
|
||||
}
|
||||
181
examples/extensions/secret/main.go
Normal file
181
examples/extensions/secret/main.go
Normal 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)),
|
||||
))
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue