detached 2cffe04

feat(skills): built-in extension-author skill, hidden from /skills

The model now ships with a `write-zot-extension` skill compiled
into the binary. When the user asks for help authoring a zot
extension (slash command, LLM tool, audit hook, permission gate)
the model sees the skill in its system-prompt manifest, calls
the `skill` tool to load the body on demand, and walks the user
through the right answer with the wire format, manifest shape,
SDK examples (Go + TS + Python), and dev workflow already in
context. No need for the user to be in the zot repo or to ask
the model to read docs/extensions.md first.

Built-in skills:
  - shipped via //go:embed at internal/skills/builtin/
  - merged into Discover()'s output AFTER user skills, so a
    user-installed skill with the same name shadows the built-in
    (drop your own SKILL.md at $ZOT_HOME/skills/write-zot-extension/
    to customise)
  - marked Builtin: true on the Skill struct
  - hidden from user-facing surfaces: VisibleSkills() filters them
    so /skills only shows skills the user actually installed or
    shipped in their project

The model side stays unchanged: system-prompt manifest still lists
built-ins (so the model knows they exist), the `skill` tool still
loads them on demand. Only the picker is filtered.

Verified live:
  prompt: "List the names of the skills you have available"
  -> code-review, test-fix, write-zot-extension

  prompt: "I want to write a zot extension that adds a slash
           command /pwd which inserts the current directory path
           into the editor. What language should I use, and what
           files do I need to create?"
  -> [tool_call] skill({"name":"write-zot-extension"})
  -> body returned
  -> the model produces a complete extension with the right
     manifest, the right hello/register/ready frames, action:
     insert correctly chosen, and a remark about cwd capture.

The picker filter has its own unit test
(TestVisibleSkillsHidesBuiltins) and the existing Discover test
was updated to expect the built-in count without hardcoding it.
commit
2cffe048c9a007df2ed454973eb5d6b02b3f1bdc
author
patriceckhart · · 2026-04-19 13:55 UTC
parent
e425dbb
signature
unsigned
refs
tag: v0.0.30
5 files · +474 -4
M internal/agent/cli.go +6 -2
@@ -409,10 +409,14 @@ func runInteractive(ctx context.Context, args Args, version string) error {
409409 Extensions: extMgr,
410410 SkillSnapshot: func() []*skills.Skill {
411411 // Re-discover so the picker reflects edits made during
412- // the session. Cheap; SKILL.md files are small.
412+ // the session. Cheap; SKILL.md files are small. Filter
413+ // out built-in skills — they're hidden from user-facing
414+ // surfaces because they're implementation detail; the
415+ // model still sees them through the system-prompt
416+ // manifest and the skill tool.
413417 userHome, _ := os.UserHomeDir()
414418 list, _ := skills.Discover(ZotHome(), r.CWD, userHome)
415- return list
419+ return skills.VisibleSkills(list)
416420 },
417421 PersistModel: func(providerName, model string) {
418422 // Update config.json so next launch uses the same pick.
@@ -409,10 +409,14 @@ func runInteractive(ctx context.Context, args Args, version string) error {
409 Extensions: extMgr,409 Extensions: extMgr,
410 SkillSnapshot: func() []*skills.Skill {410 SkillSnapshot: func() []*skills.Skill {
411 // Re-discover so the picker reflects edits made during411 // Re-discover so the picker reflects edits made during
412- // the session. Cheap; SKILL.md files are small.412+ // the session. Cheap; SKILL.md files are small. Filter
413+ // out built-in skills — they're hidden from user-facing
414+ // surfaces because they're implementation detail; the
415+ // model still sees them through the system-prompt
416+ // manifest and the skill tool.
413 userHome, _ := os.UserHomeDir()417 userHome, _ := os.UserHomeDir()
414 list, _ := skills.Discover(ZotHome(), r.CWD, userHome)418 list, _ := skills.Discover(ZotHome(), r.CWD, userHome)
415- return list419+ return skills.VisibleSkills(list)
416 },420 },
417 PersistModel: func(providerName, model string) {421 PersistModel: func(providerName, model string) {
418 // Update config.json so next launch uses the same pick.422 // Update config.json so next launch uses the same pick.
A internal/skills/builtin.go +51 -0
@@ -0,0 +1,51 @@
1+package skills
2+
3+import (
4+ "embed"
5+ "io/fs"
6+ "path"
7+ "strings"
8+)
9+
10+// builtinFS holds the SKILL.md files zot ships with the binary.
11+// They appear in the catalogue as ordinary skills — same on-demand
12+// load via the `skill` tool, same /skills picker — but never need
13+// to be installed by the user. A user-installed skill with the same
14+// name shadows the built-in one (Discover's first-match-wins).
15+//
16+//go:embed all:builtin
17+var builtinFS embed.FS
18+
19+// loadBuiltins returns every SKILL.md compiled into the binary.
20+// Errors per file are silently dropped: built-ins are part of the
21+// release; if one is malformed it's a release bug we want to surface
22+// in tests, not panic in front of the user.
23+func loadBuiltins() []*Skill {
24+ entries, err := fs.ReadDir(builtinFS, "builtin")
25+ if err != nil {
26+ return nil
27+ }
28+ var out []*Skill
29+ for _, e := range entries {
30+ if !e.IsDir() {
31+ continue
32+ }
33+ raw, err := fs.ReadFile(builtinFS, path.Join("builtin", e.Name(), "SKILL.md"))
34+ if err != nil {
35+ continue
36+ }
37+ front, body := splitFrontmatter(string(raw))
38+ s := &Skill{
39+ Path: "builtin:" + e.Name(),
40+ Source: "built-in",
41+ Body: strings.TrimSpace(body),
42+ Builtin: true,
43+ }
44+ parseFrontmatter(front, s)
45+ if s.Name == "" {
46+ s.Name = e.Name()
47+ }
48+ out = append(out, s)
49+ }
50+ return out
51+}
@@ -0,0 +1,51 @@
1+package skills
2+
3+import (
4+ "embed"
5+ "io/fs"
6+ "path"
7+ "strings"
8+)
9+
10+// builtinFS holds the SKILL.md files zot ships with the binary.
11+// They appear in the catalogue as ordinary skills — same on-demand
12+// load via the `skill` tool, same /skills picker — but never need
13+// to be installed by the user. A user-installed skill with the same
14+// name shadows the built-in one (Discover's first-match-wins).
15+//
16+//go:embed all:builtin
17+var builtinFS embed.FS
18+
19+// loadBuiltins returns every SKILL.md compiled into the binary.
20+// Errors per file are silently dropped: built-ins are part of the
21+// release; if one is malformed it's a release bug we want to surface
22+// in tests, not panic in front of the user.
23+func loadBuiltins() []*Skill {
24+ entries, err := fs.ReadDir(builtinFS, "builtin")
25+ if err != nil {
26+ return nil
27+ }
28+ var out []*Skill
29+ for _, e := range entries {
30+ if !e.IsDir() {
31+ continue
32+ }
33+ raw, err := fs.ReadFile(builtinFS, path.Join("builtin", e.Name(), "SKILL.md"))
34+ if err != nil {
35+ continue
36+ }
37+ front, body := splitFrontmatter(string(raw))
38+ s := &Skill{
39+ Path: "builtin:" + e.Name(),
40+ Source: "built-in",
41+ Body: strings.TrimSpace(body),
42+ Builtin: true,
43+ }
44+ parseFrontmatter(front, s)
45+ if s.Name == "" {
46+ s.Name = e.Name()
47+ }
48+ out = append(out, s)
49+ }
50+ return out
51+}
A internal/skills/builtin/write-zot-extension/SKILL.md +353 -0
@@ -0,0 +1,353 @@
1+---
2+name: write-zot-extension
3+description: Help the user create a new zot extension (slash command, LLM tool, or guard) in any language.
4+---
5+
6+# Writing a zot extension
7+
8+Use this skill when the user asks for help building a zot extension —
9+a new slash command, a new tool the LLM can call, an audit hook, or
10+a permission gate. Skim this whole skill first, then collaborate
11+with the user on the specific extension they want.
12+
13+## What an extension is
14+
15+A zot extension is **an external executable** that zot launches as a
16+subprocess and talks to over its stdin/stdout in newline-delimited
17+JSON. It can be written in any language that can read/write JSON
18+lines from stdio: Go, TypeScript (via tsx), Python, Rust, shell with
19+jq, anything. Crash isolation is automatic; one bad extension never
20+takes down zot.
21+
22+Three things an extension can do (any combination):
23+
24+1. **Slash commands** — register `/foo` so the user can run it from
25+ the input. The handler returns a "prompt" (submitted to the
26+ agent), an "insert" (text dropped into the editor), a "display"
27+ (one-shot styled note in the chat), or a "noop".
28+
29+2. **Tools** — register tools the LLM itself calls. Schema is
30+ JSON Schema; zot routes the model's `tool_call` to the
31+ extension's `tool_result`. Same lifecycle as built-in tools
32+ (read/write/edit/bash/skill).
33+
34+3. **Lifecycle hooks** — subscribe to events
35+ (session_start, turn_start, tool_call, turn_end,
36+ assistant_message) for telemetry / audit / custom UI, or
37+ intercept tool calls before execution to refuse dangerous
38+ patterns.
39+
40+## On-disk layout
41+
42+Each extension lives in its own directory:
43+
44+```
45+~/Library/Application Support/zot/extensions/<name>/
46+├── extension.json # manifest (required)
47+└── <executable> # whatever exec points at
48+```
49+
50+Or project-local: `<project>/.zot/extensions/<name>/`. Project-local
51+wins on name conflict.
52+
53+For ad-hoc use during development, skip the install step entirely
54+and run `zot --ext PATH` (repeatable: `-e PATH -e PATH`).
55+
56+### Manifest
57+
58+```json
59+{
60+ "name": "weather",
61+ "version": "1.0.0",
62+ "exec": "./weather",
63+ "args": [],
64+ "language": "go",
65+ "description": "current weather lookups for any city",
66+ "enabled": true
67+}
68+```
69+
70+Field rules:
71+- `name` (required, unique) — id zot uses internally; matches the
72+ hello frame. Slash commands & tools live in the same name space
73+ as built-ins; conflicts are silently shadowed by built-ins.
74+- `exec` (required) — the executable path. Resolution:
75+ - absolute: as-is
76+ - starts with `./` or `../`: relative to the manifest's directory
77+ - bare name (no separator): looked up via `$PATH` (e.g. `node`,
78+ `python3`, `npx`, `tsx`)
79+- `args` — extra argv passed to `exec` (e.g. `["index.js"]`)
80+- `language` — informational only (`"go"`, `"typescript"`,
81+ `"python"`, etc.)
82+- `enabled` — defaults to true; set false to keep installed but skip
83+
84+## Wire format
85+
86+Newline-delimited JSON in both directions. Top-level `type` is the
87+discriminator. Optional `id` correlates command/tool requests with
88+their responses.
89+
90+### Required handshake
91+
92+The very first frame the extension sends is `hello`:
93+
94+```json
95+{"type":"hello","name":"weather","version":"1.0.0",
96+ "capabilities":["commands","tools"]}
97+```
98+
99+Capabilities are advisory; current values are `commands`, `tools`,
100+`events`. Send all that apply.
101+
102+zot replies with `hello_ack`:
103+
104+```json
105+{"type":"hello_ack","protocol_version":1,"zot_version":"0.0.x",
106+ "provider":"anthropic","model":"claude-opus-4-7","cwd":"/path/to/project"}
107+```
108+
109+### Registration (immediately after hello)
110+
111+Send registration frames in any order, then a single `ready`
112+sentinel so zot can finalize the agent's tool registry:
113+
114+```json
115+{"type":"register_command","name":"weather","description":"current weather"}
116+{"type":"register_tool","name":"weather","description":"Get current weather for a city.",
117+ "schema":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}
118+{"type":"subscribe","events":["tool_call"],"intercept":["tool_call"]}
119+{"type":"ready"}
120+```
121+
122+If you don't send `ready`, zot's idle watchdog auto-treats you as
123+ready after 250ms of no frames, but always send it explicitly when
124+you can — newer extensions on faster hosts shave that 250ms off.
125+
126+### Runtime frames
127+
128+**zot → extension:**
129+
130+```json
131+{"type":"command_invoked","id":"abc","name":"weather","args":"berlin"}
132+{"type":"tool_call","id":"def","name":"weather","args":{"city":"Berlin"}}
133+{"type":"event","event":"turn_start","step":1}
134+{"type":"event_intercept","id":"ghi","event":"tool_call",
135+ "tool_name":"bash","tool_args":{"command":"rm -rf /tmp/foo"}}
136+{"type":"shutdown"}
137+```
138+
139+**extension → zot (replies + spontaneous notifications):**
140+
141+```json
142+{"type":"command_response","id":"abc","action":"prompt",
143+ "prompt":"Show today's weather for Berlin in one line."}
144+{"type":"tool_result","id":"def","content":[{"type":"text","text":"Berlin: 16°C, fog"}]}
145+{"type":"event_intercept_response","id":"ghi","block":true,
146+ "reason":"refused: command matches the danger pattern \"rm -rf\""}
147+{"type":"notify","level":"info","message":"refreshed cache"}
148+{"type":"shutdown_ack"}
149+```
150+
151+`command_response.action` values:
152+- `"prompt"` — submit `prompt` as a fresh user message
153+- `"insert"` — drop `insert` into the editor at the cursor
154+- `"display"` — append `display` to chat as a one-shot note (no
155+ model call, not in transcript)
156+- `"noop"` — handled internally; zot doesn't change the UI
157+
158+`tool_result.content[]` blocks: `{"type":"text","text":"..."}` or
159+`{"type":"image","mime_type":"image/png","data":"<base64>"}`.
160+
161+Per-tool timeout: 60s. Per-intercept timeout: 5s. Missing the
162+intercept timeout is treated as "allow" so an unresponsive guard
163+never stalls the agent.
164+
165+## Important rules
166+
167+- **stdout is reserved for the protocol.** Anything you print to
168+ stdout that isn't a JSON frame breaks the wire. Use stderr for
169+ logs / debug output (zot captures stderr to
170+ `$ZOT_HOME/logs/ext-<name>.log`).
171+- **One JSON object per line.** No multi-line JSON. Always end
172+ every frame with `\n`.
173+- **Flush after writing.** Most stdout writes are line-buffered when
174+ piped, which is fine, but explicitly flushing avoids surprise
175+ buffering on slow handlers.
176+- Extension processes inherit the user's permissions. A bad
177+ extension can do anything the user can.
178+
179+## Recommended layout per language
180+
181+### Go (use the built-in SDK at pkg/zotext)
182+
183+```go
184+package main
185+
186+import (
187+ "encoding/json"
188+ "github.com/patriceckhart/zot/pkg/zotext"
189+)
190+
191+func main() {
192+ ext := zotext.New("weather", "1.0.0")
193+
194+ ext.Command("weather", "current weather for a city",
195+ func(args string) zotext.Response {
196+ return zotext.Prompt("Tell me the weather for " + args)
197+ })
198+
199+ ext.Tool("weather", "Get current weather for a city.",
200+ json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}`),
201+ func(args json.RawMessage) zotext.ToolResult {
202+ var in struct{ City string `json:"city"` }
203+ if err := json.Unmarshal(args, &in); err != nil {
204+ return zotext.TextErrorResult("invalid args")
205+ }
206+ return zotext.TextResult(in.City + ": sunny, 21°C (fake)")
207+ })
208+
209+ if err := ext.Run(); err != nil {
210+ ext.Logf("fatal: %v", err)
211+ }
212+}
213+```
214+
215+Build: `go build -o weather .`
216+
217+`extension.json`:
218+```json
219+{"name":"weather","version":"1.0.0","exec":"./weather","language":"go","enabled":true}
220+```
221+
222+### TypeScript (no SDK; handles the protocol directly)
223+
224+Run via `tsx`, which executes `.ts` files without a build step.
225+
226+```json
227+{"name":"scratchpad","version":"1.0.0","exec":"tsx","args":["index.ts"],"language":"typescript","enabled":true}
228+```
229+
230+```typescript
231+// index.ts (excerpt; see examples/extensions/scratchpad/index.ts for the full version)
232+import { createInterface } from "node:readline";
233+import { stderr, stdin, stdout } from "node:process";
234+
235+function send(o: object) { stdout.write(JSON.stringify(o) + "\n"); }
236+function log(s: string) { stderr.write(`[scratchpad] ${s}\n`); }
237+
238+send({ type: "hello", name: "scratchpad", version: "1.0.0",
239+ capabilities: ["commands", "tools"] });
240+send({ type: "register_command", name: "note", description: "append a note" });
241+send({ type: "register_tool", name: "read_notes",
242+ description: "Read the user's scratchpad notes.",
243+ schema: { type: "object", properties: {} } });
244+send({ type: "ready" });
245+
246+const rl = createInterface({ input: stdin, crlfDelay: Infinity });
247+rl.on("line", (line) => {
248+ const f = JSON.parse(line);
249+ if (f.type === "command_invoked" && f.name === "note") {
250+ send({ type: "command_response", id: f.id, action: "display",
251+ display: `noted: ${f.args}` });
252+ } else if (f.type === "tool_call" && f.name === "read_notes") {
253+ send({ type: "tool_result", id: f.id,
254+ content: [{ type: "text", text: "(notes go here)" }] });
255+ } else if (f.type === "shutdown") {
256+ send({ type: "shutdown_ack" });
257+ rl.close();
258+ }
259+});
260+```
261+
262+`tsx` install: `npm install -g tsx`. Without global tsx, fall back
263+to `"exec":"npx","args":["--yes","tsx","index.ts"]` (slower
264+startup; npx checks the registry every launch).
265+
266+### Python
267+
268+```json
269+{"name":"hello-py","version":"1.0.0","exec":"./hello.py","language":"python","enabled":true}
270+```
271+
272+```python
273+#!/usr/bin/env python3
274+import json, sys
275+
276+def emit(o): sys.stdout.write(json.dumps(o) + "\n"); sys.stdout.flush()
277+
278+emit({"type": "hello", "name": "hello-py", "version": "1.0.0", "capabilities": ["commands"]})
279+emit({"type": "register_command", "name": "hellopy", "description": "say hi (python)"})
280+emit({"type": "ready"})
281+
282+for line in sys.stdin:
283+ msg = json.loads(line)
284+ if msg["type"] == "command_invoked":
285+ emit({"type": "command_response", "id": msg["id"],
286+ "action": "prompt", "prompt": "Say hi briefly."})
287+ elif msg["type"] == "shutdown":
288+ emit({"type": "shutdown_ack"})
289+ break
290+```
291+
292+`chmod +x hello.py`.
293+
294+## Install / dev workflow
295+
296+```bash
297+zot ext install ./weather # copy into $ZOT_HOME/extensions/
298+zot --ext ./weather # run from disk for one zot session (no install)
299+zot --ext . # cwd is the extension dir
300+zot ext list # show installed extensions
301+zot ext logs weather # cat the extension's stderr
302+zot ext logs weather -f # tail it
303+zot ext disable weather # keep installed but skip on launch
304+zot ext enable weather
305+zot ext remove weather
306+```
307+
308+For TS / Python extensions, no build step is needed — edit the source
309+in place and relaunch zot.
310+
311+For Go, run `go build -o <name> .` in the extension directory after
312+edits, then `zot ext install` (which copies the manifest + binary)
313+or `zot --ext .` to test from the working tree.
314+
315+## Manual debug
316+
317+The extension is just a process. Drive it directly with shell pipes
318+to see exactly what's happening on the wire:
319+
320+```bash
321+{
322+ printf '%s\n' '{"type":"hello_ack","protocol_version":1,"zot_version":"x","provider":"a","model":"o","cwd":"/tmp"}'
323+ sleep 0.2
324+ printf '%s\n' '{"type":"command_invoked","id":"1","name":"weather","args":"Berlin"}'
325+ sleep 0.5
326+ printf '%s\n' '{"type":"shutdown"}'
327+} | ./weather
328+```
329+
330+Compare what comes out of stdout to the expected wire format. If a
331+frame doesn't match what zot expects, it's discarded silently and
332+logged to `ext-<name>.log`.
333+
334+## Process to follow with the user
335+
336+1. Ask what the extension should DO. One sentence.
337+2. Pick the right capability:
338+ - "I want a slash command that triggers a prompt" → `command` only
339+ - "I want the model to be able to do X" → `tool`
340+ - "I want to gate / log every bash command" → `event` + `intercept`
341+3. Pick a language. Default to **Go via pkg/zotext** for new
342+ extensions if the user has Go installed; **TypeScript via tsx**
343+ if they prefer JS-flavored ergonomics; **Python** for one-off
344+ scripts.
345+4. Write the extension dir (manifest + source).
346+5. For Go, build it. For TS / Python, mark the script executable.
347+6. Suggest `zot --ext <path>` for testing without committing to an
348+ install.
349+7. When happy, `zot ext install <path>`.
350+
351+Don't try to write a full SDK or framework on top of the protocol
352+unless the user asked for one — the wire format is small enough
353+that a 30-line raw script is the right answer for most extensions.
@@ -0,0 +1,353 @@
1+---
2+name: write-zot-extension
3+description: Help the user create a new zot extension (slash command, LLM tool, or guard) in any language.
4+---
5+
6+# Writing a zot extension
7+
8+Use this skill when the user asks for help building a zot extension —
9+a new slash command, a new tool the LLM can call, an audit hook, or
10+a permission gate. Skim this whole skill first, then collaborate
11+with the user on the specific extension they want.
12+
13+## What an extension is
14+
15+A zot extension is **an external executable** that zot launches as a
16+subprocess and talks to over its stdin/stdout in newline-delimited
17+JSON. It can be written in any language that can read/write JSON
18+lines from stdio: Go, TypeScript (via tsx), Python, Rust, shell with
19+jq, anything. Crash isolation is automatic; one bad extension never
20+takes down zot.
21+
22+Three things an extension can do (any combination):
23+
24+1. **Slash commands** — register `/foo` so the user can run it from
25+ the input. The handler returns a "prompt" (submitted to the
26+ agent), an "insert" (text dropped into the editor), a "display"
27+ (one-shot styled note in the chat), or a "noop".
28+
29+2. **Tools** — register tools the LLM itself calls. Schema is
30+ JSON Schema; zot routes the model's `tool_call` to the
31+ extension's `tool_result`. Same lifecycle as built-in tools
32+ (read/write/edit/bash/skill).
33+
34+3. **Lifecycle hooks** — subscribe to events
35+ (session_start, turn_start, tool_call, turn_end,
36+ assistant_message) for telemetry / audit / custom UI, or
37+ intercept tool calls before execution to refuse dangerous
38+ patterns.
39+
40+## On-disk layout
41+
42+Each extension lives in its own directory:
43+
44+```
45+~/Library/Application Support/zot/extensions/<name>/
46+├── extension.json # manifest (required)
47+└── <executable> # whatever exec points at
48+```
49+
50+Or project-local: `<project>/.zot/extensions/<name>/`. Project-local
51+wins on name conflict.
52+
53+For ad-hoc use during development, skip the install step entirely
54+and run `zot --ext PATH` (repeatable: `-e PATH -e PATH`).
55+
56+### Manifest
57+
58+```json
59+{
60+ "name": "weather",
61+ "version": "1.0.0",
62+ "exec": "./weather",
63+ "args": [],
64+ "language": "go",
65+ "description": "current weather lookups for any city",
66+ "enabled": true
67+}
68+```
69+
70+Field rules:
71+- `name` (required, unique) — id zot uses internally; matches the
72+ hello frame. Slash commands & tools live in the same name space
73+ as built-ins; conflicts are silently shadowed by built-ins.
74+- `exec` (required) — the executable path. Resolution:
75+ - absolute: as-is
76+ - starts with `./` or `../`: relative to the manifest's directory
77+ - bare name (no separator): looked up via `$PATH` (e.g. `node`,
78+ `python3`, `npx`, `tsx`)
79+- `args` — extra argv passed to `exec` (e.g. `["index.js"]`)
80+- `language` — informational only (`"go"`, `"typescript"`,
81+ `"python"`, etc.)
82+- `enabled` — defaults to true; set false to keep installed but skip
83+
84+## Wire format
85+
86+Newline-delimited JSON in both directions. Top-level `type` is the
87+discriminator. Optional `id` correlates command/tool requests with
88+their responses.
89+
90+### Required handshake
91+
92+The very first frame the extension sends is `hello`:
93+
94+```json
95+{"type":"hello","name":"weather","version":"1.0.0",
96+ "capabilities":["commands","tools"]}
97+```
98+
99+Capabilities are advisory; current values are `commands`, `tools`,
100+`events`. Send all that apply.
101+
102+zot replies with `hello_ack`:
103+
104+```json
105+{"type":"hello_ack","protocol_version":1,"zot_version":"0.0.x",
106+ "provider":"anthropic","model":"claude-opus-4-7","cwd":"/path/to/project"}
107+```
108+
109+### Registration (immediately after hello)
110+
111+Send registration frames in any order, then a single `ready`
112+sentinel so zot can finalize the agent's tool registry:
113+
114+```json
115+{"type":"register_command","name":"weather","description":"current weather"}
116+{"type":"register_tool","name":"weather","description":"Get current weather for a city.",
117+ "schema":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}
118+{"type":"subscribe","events":["tool_call"],"intercept":["tool_call"]}
119+{"type":"ready"}
120+```
121+
122+If you don't send `ready`, zot's idle watchdog auto-treats you as
123+ready after 250ms of no frames, but always send it explicitly when
124+you can — newer extensions on faster hosts shave that 250ms off.
125+
126+### Runtime frames
127+
128+**zot → extension:**
129+
130+```json
131+{"type":"command_invoked","id":"abc","name":"weather","args":"berlin"}
132+{"type":"tool_call","id":"def","name":"weather","args":{"city":"Berlin"}}
133+{"type":"event","event":"turn_start","step":1}
134+{"type":"event_intercept","id":"ghi","event":"tool_call",
135+ "tool_name":"bash","tool_args":{"command":"rm -rf /tmp/foo"}}
136+{"type":"shutdown"}
137+```
138+
139+**extension → zot (replies + spontaneous notifications):**
140+
141+```json
142+{"type":"command_response","id":"abc","action":"prompt",
143+ "prompt":"Show today's weather for Berlin in one line."}
144+{"type":"tool_result","id":"def","content":[{"type":"text","text":"Berlin: 16°C, fog"}]}
145+{"type":"event_intercept_response","id":"ghi","block":true,
146+ "reason":"refused: command matches the danger pattern \"rm -rf\""}
147+{"type":"notify","level":"info","message":"refreshed cache"}
148+{"type":"shutdown_ack"}
149+```
150+
151+`command_response.action` values:
152+- `"prompt"` — submit `prompt` as a fresh user message
153+- `"insert"` — drop `insert` into the editor at the cursor
154+- `"display"` — append `display` to chat as a one-shot note (no
155+ model call, not in transcript)
156+- `"noop"` — handled internally; zot doesn't change the UI
157+
158+`tool_result.content[]` blocks: `{"type":"text","text":"..."}` or
159+`{"type":"image","mime_type":"image/png","data":"<base64>"}`.
160+
161+Per-tool timeout: 60s. Per-intercept timeout: 5s. Missing the
162+intercept timeout is treated as "allow" so an unresponsive guard
163+never stalls the agent.
164+
165+## Important rules
166+
167+- **stdout is reserved for the protocol.** Anything you print to
168+ stdout that isn't a JSON frame breaks the wire. Use stderr for
169+ logs / debug output (zot captures stderr to
170+ `$ZOT_HOME/logs/ext-<name>.log`).
171+- **One JSON object per line.** No multi-line JSON. Always end
172+ every frame with `\n`.
173+- **Flush after writing.** Most stdout writes are line-buffered when
174+ piped, which is fine, but explicitly flushing avoids surprise
175+ buffering on slow handlers.
176+- Extension processes inherit the user's permissions. A bad
177+ extension can do anything the user can.
178+
179+## Recommended layout per language
180+
181+### Go (use the built-in SDK at pkg/zotext)
182+
183+```go
184+package main
185+
186+import (
187+ "encoding/json"
188+ "github.com/patriceckhart/zot/pkg/zotext"
189+)
190+
191+func main() {
192+ ext := zotext.New("weather", "1.0.0")
193+
194+ ext.Command("weather", "current weather for a city",
195+ func(args string) zotext.Response {
196+ return zotext.Prompt("Tell me the weather for " + args)
197+ })
198+
199+ ext.Tool("weather", "Get current weather for a city.",
200+ json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}`),
201+ func(args json.RawMessage) zotext.ToolResult {
202+ var in struct{ City string `json:"city"` }
203+ if err := json.Unmarshal(args, &in); err != nil {
204+ return zotext.TextErrorResult("invalid args")
205+ }
206+ return zotext.TextResult(in.City + ": sunny, 21°C (fake)")
207+ })
208+
209+ if err := ext.Run(); err != nil {
210+ ext.Logf("fatal: %v", err)
211+ }
212+}
213+```
214+
215+Build: `go build -o weather .`
216+
217+`extension.json`:
218+```json
219+{"name":"weather","version":"1.0.0","exec":"./weather","language":"go","enabled":true}
220+```
221+
222+### TypeScript (no SDK; handles the protocol directly)
223+
224+Run via `tsx`, which executes `.ts` files without a build step.
225+
226+```json
227+{"name":"scratchpad","version":"1.0.0","exec":"tsx","args":["index.ts"],"language":"typescript","enabled":true}
228+```
229+
230+```typescript
231+// index.ts (excerpt; see examples/extensions/scratchpad/index.ts for the full version)
232+import { createInterface } from "node:readline";
233+import { stderr, stdin, stdout } from "node:process";
234+
235+function send(o: object) { stdout.write(JSON.stringify(o) + "\n"); }
236+function log(s: string) { stderr.write(`[scratchpad] ${s}\n`); }
237+
238+send({ type: "hello", name: "scratchpad", version: "1.0.0",
239+ capabilities: ["commands", "tools"] });
240+send({ type: "register_command", name: "note", description: "append a note" });
241+send({ type: "register_tool", name: "read_notes",
242+ description: "Read the user's scratchpad notes.",
243+ schema: { type: "object", properties: {} } });
244+send({ type: "ready" });
245+
246+const rl = createInterface({ input: stdin, crlfDelay: Infinity });
247+rl.on("line", (line) => {
248+ const f = JSON.parse(line);
249+ if (f.type === "command_invoked" && f.name === "note") {
250+ send({ type: "command_response", id: f.id, action: "display",
251+ display: `noted: ${f.args}` });
252+ } else if (f.type === "tool_call" && f.name === "read_notes") {
253+ send({ type: "tool_result", id: f.id,
254+ content: [{ type: "text", text: "(notes go here)" }] });
255+ } else if (f.type === "shutdown") {
256+ send({ type: "shutdown_ack" });
257+ rl.close();
258+ }
259+});
260+```
261+
262+`tsx` install: `npm install -g tsx`. Without global tsx, fall back
263+to `"exec":"npx","args":["--yes","tsx","index.ts"]` (slower
264+startup; npx checks the registry every launch).
265+
266+### Python
267+
268+```json
269+{"name":"hello-py","version":"1.0.0","exec":"./hello.py","language":"python","enabled":true}
270+```
271+
272+```python
273+#!/usr/bin/env python3
274+import json, sys
275+
276+def emit(o): sys.stdout.write(json.dumps(o) + "\n"); sys.stdout.flush()
277+
278+emit({"type": "hello", "name": "hello-py", "version": "1.0.0", "capabilities": ["commands"]})
279+emit({"type": "register_command", "name": "hellopy", "description": "say hi (python)"})
280+emit({"type": "ready"})
281+
282+for line in sys.stdin:
283+ msg = json.loads(line)
284+ if msg["type"] == "command_invoked":
285+ emit({"type": "command_response", "id": msg["id"],
286+ "action": "prompt", "prompt": "Say hi briefly."})
287+ elif msg["type"] == "shutdown":
288+ emit({"type": "shutdown_ack"})
289+ break
290+```
291+
292+`chmod +x hello.py`.
293+
294+## Install / dev workflow
295+
296+```bash
297+zot ext install ./weather # copy into $ZOT_HOME/extensions/
298+zot --ext ./weather # run from disk for one zot session (no install)
299+zot --ext . # cwd is the extension dir
300+zot ext list # show installed extensions
301+zot ext logs weather # cat the extension's stderr
302+zot ext logs weather -f # tail it
303+zot ext disable weather # keep installed but skip on launch
304+zot ext enable weather
305+zot ext remove weather
306+```
307+
308+For TS / Python extensions, no build step is needed — edit the source
309+in place and relaunch zot.
310+
311+For Go, run `go build -o <name> .` in the extension directory after
312+edits, then `zot ext install` (which copies the manifest + binary)
313+or `zot --ext .` to test from the working tree.
314+
315+## Manual debug
316+
317+The extension is just a process. Drive it directly with shell pipes
318+to see exactly what's happening on the wire:
319+
320+```bash
321+{
322+ printf '%s\n' '{"type":"hello_ack","protocol_version":1,"zot_version":"x","provider":"a","model":"o","cwd":"/tmp"}'
323+ sleep 0.2
324+ printf '%s\n' '{"type":"command_invoked","id":"1","name":"weather","args":"Berlin"}'
325+ sleep 0.5
326+ printf '%s\n' '{"type":"shutdown"}'
327+} | ./weather
328+```
329+
330+Compare what comes out of stdout to the expected wire format. If a
331+frame doesn't match what zot expects, it's discarded silently and
332+logged to `ext-<name>.log`.
333+
334+## Process to follow with the user
335+
336+1. Ask what the extension should DO. One sentence.
337+2. Pick the right capability:
338+ - "I want a slash command that triggers a prompt" → `command` only
339+ - "I want the model to be able to do X" → `tool`
340+ - "I want to gate / log every bash command" → `event` + `intercept`
341+3. Pick a language. Default to **Go via pkg/zotext** for new
342+ extensions if the user has Go installed; **TypeScript via tsx**
343+ if they prefer JS-flavored ergonomics; **Python** for one-off
344+ scripts.
345+4. Write the extension dir (manifest + source).
346+5. For Go, build it. For TS / Python, mark the script executable.
347+6. Suggest `zot --ext <path>` for testing without committing to an
348+ install.
349+7. When happy, `zot ext install <path>`.
350+
351+Don't try to write a full SDK or framework on top of the protocol
352+unless the user asked for one — the wire format is small enough
353+that a 30-line raw script is the right answer for most extensions.
M internal/skills/skills.go +35 -0
@@ -54,6 +54,13 @@ type Skill struct {
5454 // Shown in the /skills picker.
5555 Source string
5656
57+ // Builtin marks skills that ship inside the zot binary. They are
58+ // fully active for the model (system-prompt manifest + skill
59+ // tool) but hidden from user-facing surfaces like the /skills
60+ // picker so users only see skills they actually installed or
61+ // shipped in their project.
62+ Builtin bool
63+
5764 // AllowedTools and Permissions are parsed for forward-
5865 // compatibility but NOT enforced in this version. They appear
5966 // in the skill body so the model can self-regulate.
@@ -61,11 +68,31 @@ type Skill struct {
6168 Permissions map[string][]string
6269 }
6370
71+// VisibleSkills returns the subset of skills users should see in
72+// pickers, /skills, and other interactive surfaces. Built-ins are
73+// hidden because they're implementation detail; the model still
74+// loads them through the system-prompt manifest + the skill tool.
75+func VisibleSkills(in []*Skill) []*Skill {
76+ out := make([]*Skill, 0, len(in))
77+ for _, s := range in {
78+ if s == nil || s.Builtin {
79+ continue
80+ }
81+ out = append(out, s)
82+ }
83+ return out
84+}
85+
6486 // Discover walks every supported location, parses each SKILL.md, and
6587 // returns the merged skill set. First-match-wins per name; the order
6688 // matches the priority list in the package doc. Errors per skill are
6789 // returned alongside the partial result so a single broken file
6890 // doesn't suppress the rest.
91+//
92+// Built-in skills (compiled into the zot binary) are added LAST so
93+// any user-installed skill with the same name shadows the built-in.
94+// That lets users customise the help text by dropping their own
95+// SKILL.md with the same name into $ZOT_HOME/skills/<name>/.
6996 func Discover(zotHome, cwd, userHome string) ([]*Skill, []error) {
7097 var errs []error
7198 seen := map[string]*Skill{}
@@ -95,6 +122,14 @@ func Discover(zotHome, cwd, userHome string) ([]*Skill, []error) {
95122 seen[s.Name] = s
96123 }
97124 }
125+ // Built-ins fill in any name the user didn't already provide.
126+ for _, s := range loadBuiltins() {
127+ if _, dup := seen[s.Name]; dup {
128+ continue
129+ }
130+ seen[s.Name] = s
131+ }
132+
98133 out := make([]*Skill, 0, len(seen))
99134 for _, s := range seen {
100135 out = append(out, s)
@@ -54,6 +54,13 @@ type Skill struct {
54 // Shown in the /skills picker.54 // Shown in the /skills picker.
55 Source string55 Source string
56 56
57+ // Builtin marks skills that ship inside the zot binary. They are
58+ // fully active for the model (system-prompt manifest + skill
59+ // tool) but hidden from user-facing surfaces like the /skills
60+ // picker so users only see skills they actually installed or
61+ // shipped in their project.
62+ Builtin bool
63+
57 // AllowedTools and Permissions are parsed for forward-64 // AllowedTools and Permissions are parsed for forward-
58 // compatibility but NOT enforced in this version. They appear65 // compatibility but NOT enforced in this version. They appear
59 // in the skill body so the model can self-regulate.66 // in the skill body so the model can self-regulate.
@@ -61,11 +68,31 @@ type Skill struct {
61 Permissions map[string][]string68 Permissions map[string][]string
62 }69 }
63 70
71+// VisibleSkills returns the subset of skills users should see in
72+// pickers, /skills, and other interactive surfaces. Built-ins are
73+// hidden because they're implementation detail; the model still
74+// loads them through the system-prompt manifest + the skill tool.
75+func VisibleSkills(in []*Skill) []*Skill {
76+ out := make([]*Skill, 0, len(in))
77+ for _, s := range in {
78+ if s == nil || s.Builtin {
79+ continue
80+ }
81+ out = append(out, s)
82+ }
83+ return out
84+}
85+
64 // Discover walks every supported location, parses each SKILL.md, and86 // Discover walks every supported location, parses each SKILL.md, and
65 // returns the merged skill set. First-match-wins per name; the order87 // returns the merged skill set. First-match-wins per name; the order
66 // matches the priority list in the package doc. Errors per skill are88 // matches the priority list in the package doc. Errors per skill are
67 // returned alongside the partial result so a single broken file89 // returned alongside the partial result so a single broken file
68 // doesn't suppress the rest.90 // doesn't suppress the rest.
91+//
92+// Built-in skills (compiled into the zot binary) are added LAST so
93+// any user-installed skill with the same name shadows the built-in.
94+// That lets users customise the help text by dropping their own
95+// SKILL.md with the same name into $ZOT_HOME/skills/<name>/.
69 func Discover(zotHome, cwd, userHome string) ([]*Skill, []error) {96 func Discover(zotHome, cwd, userHome string) ([]*Skill, []error) {
70 var errs []error97 var errs []error
71 seen := map[string]*Skill{}98 seen := map[string]*Skill{}
@@ -95,6 +122,14 @@ func Discover(zotHome, cwd, userHome string) ([]*Skill, []error) {
95 seen[s.Name] = s122 seen[s.Name] = s
96 }123 }
97 }124 }
125+ // Built-ins fill in any name the user didn't already provide.
126+ for _, s := range loadBuiltins() {
127+ if _, dup := seen[s.Name]; dup {
128+ continue
129+ }
130+ seen[s.Name] = s
131+ }
132+
98 out := make([]*Skill, 0, len(seen))133 out := make([]*Skill, 0, len(seen))
99 for _, s := range seen {134 for _, s := range seen {
100 out = append(out, s)135 out = append(out, s)
M internal/skills/skills_test.go +29 -2
@@ -67,8 +67,12 @@ func TestDiscoverProjectAndGlobalPriorityAndDedup(t *testing.T) {
6767 if len(errs) > 0 {
6868 t.Fatalf("errs: %v", errs)
6969 }
70- if len(skills) != 2 {
71- t.Fatalf("expected 2 skills, got %d (%v)", len(skills), skills)
70+ // Expect the two user skills + every built-in shipped with the
71+ // binary (currently the write-zot-extension authoring guide).
72+ builtins := loadBuiltins()
73+ want := 2 + len(builtins)
74+ if len(skills) != want {
75+ t.Fatalf("expected %d skills (2 user + %d built-in), got %d (%v)", want, len(builtins), len(skills), skills)
7276 }
7377 shared := FindByName(skills, "shared")
7478 if shared == nil || shared.Description != "project version" {
@@ -77,6 +81,29 @@ func TestDiscoverProjectAndGlobalPriorityAndDedup(t *testing.T) {
7781 if FindByName(skills, "global-only") == nil {
7882 t.Errorf("global-only skill missing")
7983 }
84+ // At least one built-in should have made it through.
85+ for _, b := range builtins {
86+ if FindByName(skills, b.Name) == nil {
87+ t.Errorf("built-in skill %q missing from Discover output", b.Name)
88+ }
89+ }
90+}
91+
92+func TestVisibleSkillsHidesBuiltins(t *testing.T) {
93+ in := []*Skill{
94+ {Name: "user-one"},
95+ {Name: "built-one", Builtin: true},
96+ {Name: "user-two"},
97+ }
98+ out := VisibleSkills(in)
99+ if len(out) != 2 {
100+ t.Fatalf("expected 2 visible skills, got %d (%v)", len(out), out)
101+ }
102+ for _, s := range out {
103+ if s.Builtin {
104+ t.Errorf("built-in %q leaked into visible set", s.Name)
105+ }
106+ }
80107 }
81108
82109 func TestSystemPromptAddendum(t *testing.T) {
@@ -67,8 +67,12 @@ func TestDiscoverProjectAndGlobalPriorityAndDedup(t *testing.T) {
67 if len(errs) > 0 {67 if len(errs) > 0 {
68 t.Fatalf("errs: %v", errs)68 t.Fatalf("errs: %v", errs)
69 }69 }
70- if len(skills) != 2 {70+ // Expect the two user skills + every built-in shipped with the
71- t.Fatalf("expected 2 skills, got %d (%v)", len(skills), skills)71+ // binary (currently the write-zot-extension authoring guide).
72+ builtins := loadBuiltins()
73+ want := 2 + len(builtins)
74+ if len(skills) != want {
75+ t.Fatalf("expected %d skills (2 user + %d built-in), got %d (%v)", want, len(builtins), len(skills), skills)
72 }76 }
73 shared := FindByName(skills, "shared")77 shared := FindByName(skills, "shared")
74 if shared == nil || shared.Description != "project version" {78 if shared == nil || shared.Description != "project version" {
@@ -77,6 +81,29 @@ func TestDiscoverProjectAndGlobalPriorityAndDedup(t *testing.T) {
77 if FindByName(skills, "global-only") == nil {81 if FindByName(skills, "global-only") == nil {
78 t.Errorf("global-only skill missing")82 t.Errorf("global-only skill missing")
79 }83 }
84+ // At least one built-in should have made it through.
85+ for _, b := range builtins {
86+ if FindByName(skills, b.Name) == nil {
87+ t.Errorf("built-in skill %q missing from Discover output", b.Name)
88+ }
89+ }
90+}
91+
92+func TestVisibleSkillsHidesBuiltins(t *testing.T) {
93+ in := []*Skill{
94+ {Name: "user-one"},
95+ {Name: "built-one", Builtin: true},
96+ {Name: "user-two"},
97+ }
98+ out := VisibleSkills(in)
99+ if len(out) != 2 {
100+ t.Fatalf("expected 2 visible skills, got %d (%v)", len(out), out)
101+ }
102+ for _, s := range out {
103+ if s.Builtin {
104+ t.Errorf("built-in %q leaked into visible set", s.Name)
105+ }
106+ }
80 }107 }
81 108
82 func TestSystemPromptAddendum(t *testing.T) {109 func TestSystemPromptAddendum(t *testing.T) {

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