diff --git a/src/resources/skills/create-gsd-extension/SKILL.md b/src/resources/skills/create-gsd-extension/SKILL.md
new file mode 100644
index 000000000..e233c0229
--- /dev/null
+++ b/src/resources/skills/create-gsd-extension/SKILL.md
@@ -0,0 +1,87 @@
+---
+name: create-gsd-extension
+description: Create, debug, and iterate on GSD extensions (TypeScript modules that add tools, commands, event hooks, custom UI, and providers to GSD). Use when asked to build an extension, add a tool the LLM can call, register a slash command, hook into GSD events, create custom TUI components, or modify GSD behavior. Triggers on "create extension", "build extension", "add a tool", "register command", "hook into gsd", "custom tool", "gsd plugin", "gsd extension".
+---
+
+
+
+**Extensions are TypeScript modules** that hook into GSD's runtime (built on pi). They export a default function receiving `ExtensionAPI` and use it to subscribe to events, register tools/commands/shortcuts, and interact with the session.
+
+**GSD extension paths:**
+- Global extensions: `~/.gsd/agent/extensions/*.ts` or `~/.gsd/agent/extensions/*/index.ts`
+- Project-local extensions: `.gsd/extensions/*.ts` or `.gsd/extensions/*/index.ts`
+
+**The three primitives:**
+1. **Events** — Listen and react (`pi.on("event", handler)`). Can block tool calls, modify messages, inject context.
+2. **Tools** — Give the LLM new abilities (`pi.registerTool()`). LLM calls them autonomously.
+3. **Commands** — Give users slash commands (`pi.registerCommand()`). Users type `/mycommand`.
+
+**Non-negotiable rules:**
+- Use `StringEnum` from `@mariozechner/pi-ai` for string enum params (NOT `Type.Union`/`Type.Literal` — breaks Google's API)
+- Truncate tool output to 50KB / 2000 lines max (use `truncateHead`/`truncateTail` from `@mariozechner/pi-coding-agent`)
+- Store stateful tool state in `details` for branching support
+- Check `signal?.aborted` in long-running tool executions
+- Use `pi.exec()` not `child_process` for shell commands
+- Check `ctx.hasUI` before dialog methods (non-interactive modes exist)
+- Session control methods (`waitForIdle`, `newSession`, `fork`, `navigateTree`, `reload`) are ONLY available in command handlers — they deadlock in event handlers
+- Lines from `render()` must not exceed `width` — use `truncateToWidth()`
+- Use theme from callback params, never import directly
+- Strip leading `@` from path params in custom tools (some models add it)
+
+**Available imports:**
+
+| Package | Purpose |
+|---------|---------|
+| `@mariozechner/pi-coding-agent` | `ExtensionAPI`, `ExtensionContext`, `Theme`, event types, tool utilities, `DynamicBorder`, `BorderedLoader`, `CustomEditor`, `highlightCode` |
+| `@sinclair/typebox` | `Type.Object`, `Type.String`, `Type.Number`, `Type.Optional`, `Type.Boolean`, `Type.Array` |
+| `@mariozechner/pi-ai` | `StringEnum` (required for string enums), `Type` re-export |
+| `@mariozechner/pi-tui` | `Text`, `Box`, `Container`, `Spacer`, `Markdown`, `SelectList`, `Input`, `matchesKey`, `Key`, `truncateToWidth`, `visibleWidth` |
+| Node.js built-ins | `node:fs`, `node:path`, `node:child_process`, etc. |
+
+
+
+
+Based on user intent, route to the appropriate workflow:
+
+**Building a new extension:**
+- "Create an extension", "build a tool", "I want to add a command" → `workflows/create-extension.md`
+
+**Adding capabilities to an existing extension:**
+- "Add a tool to my extension", "add event hook", "add custom rendering" → `workflows/add-capability.md`
+
+**Debugging an extension:**
+- "My extension doesn't work", "tool not showing up", "event not firing" → `workflows/debug-extension.md`
+
+**If user intent is clear from context, skip the question and go directly to the workflow.**
+
+
+
+All domain knowledge in `references/`:
+
+**Core architecture:** extension-lifecycle.md, events-reference.md
+**API surface:** extensionapi-reference.md, extensioncontext-reference.md
+**Capabilities:** custom-tools.md, custom-commands.md, custom-ui.md, custom-rendering.md
+**Patterns:** state-management.md, system-prompt-modification.md, compaction-session-control.md
+**Infrastructure:** model-provider-management.md, remote-execution-overrides.md, packaging-distribution.md, mode-behavior.md
+**Gotchas:** key-rules-gotchas.md
+
+
+
+| Workflow | Purpose |
+|----------|---------|
+| create-extension.md | Build a new extension from scratch |
+| add-capability.md | Add tools, commands, hooks, UI to an existing extension |
+| debug-extension.md | Diagnose and fix extension issues |
+
+
+
+Extension is complete when:
+- TypeScript compiles without errors (jiti handles this at runtime)
+- Extension loads on GSD startup or `/reload` without errors
+- Tools appear in the LLM's system prompt and are callable
+- Commands respond to `/command` input
+- Event hooks fire at the expected lifecycle points
+- Custom UI renders correctly within terminal width
+- State persists correctly across session restarts (if stateful)
+- Output is truncated to safe limits (if tools produce variable output)
+
diff --git a/src/resources/skills/create-gsd-extension/references/compaction-session-control.md b/src/resources/skills/create-gsd-extension/references/compaction-session-control.md
new file mode 100644
index 000000000..9826955fb
--- /dev/null
+++ b/src/resources/skills/create-gsd-extension/references/compaction-session-control.md
@@ -0,0 +1,77 @@
+
+Custom compaction hooks, triggering compaction, and session control methods available only in command handlers.
+
+
+
+Override default compaction behavior:
+
+```typescript
+pi.on("session_before_compact", async (event, ctx) => {
+ const { preparation, branchEntries, customInstructions, signal } = event;
+
+ // Option 1: Cancel
+ return { cancel: true };
+
+ // Option 2: Custom summary
+ return {
+ compaction: {
+ summary: "Custom summary of conversation so far...",
+ firstKeptEntryId: preparation.firstKeptEntryId,
+ tokensBefore: preparation.tokensBefore,
+ }
+ };
+});
+```
+
+
+
+Trigger compaction programmatically from any handler:
+
+```typescript
+ctx.compact({
+ customInstructions: "Focus on the authentication changes",
+ onComplete: (result) => ctx.ui.notify("Compacted!", "info"),
+ onError: (error) => ctx.ui.notify(`Failed: ${error.message}`, "error"),
+});
+```
+
+
+
+**Only available in command handlers** (deadlocks in event handlers):
+
+```typescript
+pi.registerCommand("handoff", {
+ handler: async (args, ctx) => {
+ await ctx.waitForIdle();
+
+ // Create new session with initial context
+ const result = await ctx.newSession({
+ parentSession: ctx.sessionManager.getSessionFile(),
+ setup: async (sm) => {
+ sm.appendMessage({
+ role: "user",
+ content: [{ type: "text", text: `Context: ${args}` }],
+ timestamp: Date.now(),
+ });
+ },
+ });
+
+ if (result.cancelled) { /* extension cancelled via session_before_switch */ }
+ },
+});
+```
+
+| Method | Purpose |
+|--------|---------|
+| `ctx.waitForIdle()` | Wait for agent to finish streaming |
+| `ctx.newSession(options?)` | Create a new session |
+| `ctx.fork(entryId)` | Fork from a specific entry |
+| `ctx.navigateTree(targetId, options?)` | Navigate session tree (with optional summary) |
+| `ctx.reload()` | Hot-reload everything (treat as terminal — code after runs pre-reload version) |
+
+`navigateTree` options:
+- `summarize: boolean` — generate summary of abandoned branch
+- `customInstructions: string` — instructions for summarizer
+- `replaceInstructions: boolean` — replace default prompt entirely
+- `label: string` — label to attach to branch summary
+
diff --git a/src/resources/skills/create-gsd-extension/references/custom-commands.md b/src/resources/skills/create-gsd-extension/references/custom-commands.md
new file mode 100644
index 000000000..43a6c0676
--- /dev/null
+++ b/src/resources/skills/create-gsd-extension/references/custom-commands.md
@@ -0,0 +1,139 @@
+
+Custom slash commands — registration, argument completions, subcommand patterns, and the extended command context.
+
+
+
+```typescript
+pi.registerCommand("deploy", {
+ description: "Deploy to an environment",
+ handler: async (args, ctx) => {
+ // args = everything after "/deploy "
+ // ctx = ExtensionCommandContext (has session control methods)
+ ctx.ui.notify(`Deploying to ${args || "production"}`, "info");
+ },
+});
+```
+
+
+
+Add tab-completion for command arguments:
+
+```typescript
+import type { AutocompleteItem } from "@mariozechner/pi-tui";
+
+pi.registerCommand("deploy", {
+ description: "Deploy to an environment",
+ getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
+ const envs = ["dev", "staging", "prod"];
+ const items = envs.map(e => ({ value: e, label: e }));
+ const filtered = items.filter(i => i.value.startsWith(prefix));
+ return filtered.length > 0 ? filtered : null;
+ },
+ handler: async (args, ctx) => {
+ ctx.ui.notify(`Deploying to ${args}`, "info");
+ },
+});
+```
+
+
+
+Fake nested commands via first-argument parsing. Used by `/wt new|ls|switch|merge|rm`.
+
+```typescript
+pi.registerCommand("foo", {
+ description: "Manage foo items: /foo new|list|delete [name]",
+
+ getArgumentCompletions: (prefix: string) => {
+ const parts = prefix.trim().split(/\s+/);
+
+ // First arg: subcommand
+ if (parts.length <= 1) {
+ return ["new", "list", "delete"]
+ .filter(cmd => cmd.startsWith(parts[0] ?? ""))
+ .map(cmd => ({ value: cmd, label: cmd }));
+ }
+
+ // Second arg: depends on subcommand
+ if (parts[0] === "delete") {
+ const items = getItemsSomehow();
+ return items
+ .filter(name => name.startsWith(parts[1] ?? ""))
+ .map(name => ({ value: `delete ${name}`, label: name }));
+ }
+
+ return [];
+ },
+
+ handler: async (args, ctx) => {
+ const parts = args.trim().split(/\s+/);
+ const sub = parts[0];
+
+ switch (sub) {
+ case "new": /* ... */ return;
+ case "list": /* ... */ return;
+ case "delete": /* handle parts[1] */ return;
+ default:
+ ctx.ui.notify("Usage: /foo [name]", "info");
+ }
+ },
+});
+```
+
+**Gotcha:** `"".trim().split(/\s+/)` produces `['']`, not `[]`. That's why `parts.length <= 1` handles both empty and partial first arg.
+
+
+
+Command handlers get `ExtensionCommandContext` which extends `ExtensionContext` with session control methods:
+
+| Method | Purpose |
+|--------|---------|
+| `ctx.waitForIdle()` | Wait for agent to finish streaming |
+| `ctx.newSession(options?)` | Create a new session |
+| `ctx.fork(entryId)` | Fork from an entry |
+| `ctx.navigateTree(targetId, options?)` | Navigate session tree |
+| `ctx.reload()` | Hot-reload everything |
+
+**⚠️ These methods are ONLY available in command handlers.** Calling them from event handlers causes deadlocks.
+
+```typescript
+pi.registerCommand("handoff", {
+ handler: async (args, ctx) => {
+ await ctx.waitForIdle();
+ await ctx.newSession({
+ setup: async (sm) => {
+ sm.appendMessage({
+ role: "user",
+ content: [{ type: "text", text: `Context: ${args}` }],
+ timestamp: Date.now(),
+ });
+ },
+ });
+ },
+});
+```
+
+
+
+Expose reload as both a command and a tool the LLM can call:
+
+```typescript
+pi.registerCommand("reload-runtime", {
+ description: "Reload extensions, skills, prompts, and themes",
+ handler: async (_args, ctx) => {
+ await ctx.reload();
+ return; // Treat reload as terminal
+ },
+});
+
+pi.registerTool({
+ name: "reload_runtime",
+ label: "Reload Runtime",
+ description: "Reload extensions, skills, prompts, and themes",
+ parameters: Type.Object({}),
+ async execute() {
+ pi.sendUserMessage("/reload-runtime", { deliverAs: "followUp" });
+ return { content: [{ type: "text", text: "Queued /reload-runtime as follow-up." }] };
+ },
+});
+```
+
diff --git a/src/resources/skills/create-gsd-extension/references/custom-rendering.md b/src/resources/skills/create-gsd-extension/references/custom-rendering.md
new file mode 100644
index 000000000..8572af6d2
--- /dev/null
+++ b/src/resources/skills/create-gsd-extension/references/custom-rendering.md
@@ -0,0 +1,108 @@
+
+Custom rendering for tools and messages — control how they appear in the TUI.
+
+
+
+Tools can provide `renderCall` (how the call looks) and `renderResult` (how the result looks):
+
+```typescript
+import { Text } from "@mariozechner/pi-tui";
+import { keyHint } from "@mariozechner/pi-coding-agent";
+
+pi.registerTool({
+ name: "my_tool",
+ // ...
+
+ renderCall(args, theme) {
+ let text = theme.fg("toolTitle", theme.bold("my_tool "));
+ text += theme.fg("muted", args.action);
+ if (args.text) text += " " + theme.fg("dim", `"${args.text}"`);
+ return new Text(text, 0, 0); // 0,0 padding — Box handles it
+ },
+
+ renderResult(result, { expanded, isPartial }, theme) {
+ // isPartial = true during streaming (onUpdate was called)
+ if (isPartial) {
+ return new Text(theme.fg("warning", "Processing..."), 0, 0);
+ }
+
+ // expanded = user toggled expand (Ctrl+O)
+ if (result.details?.error) {
+ return new Text(theme.fg("error", `Error: ${result.details.error}`), 0, 0);
+ }
+
+ let text = theme.fg("success", "✓ Done");
+ if (!expanded) {
+ text += ` (${keyHint("expandTools", "to expand")})`;
+ }
+ if (expanded && result.details?.items) {
+ for (const item of result.details.items) {
+ text += "\n " + theme.fg("dim", item);
+ }
+ }
+ return new Text(text, 0, 0);
+ },
+});
+```
+
+If you omit `renderCall`/`renderResult`, the built-in renderer is used. Useful for tool overrides where you just wrap logic without reimplementing UI.
+
+**Fallback:** If render methods throw, `renderCall` shows tool name, `renderResult` shows raw `content` text.
+
+
+
+Key hint helpers for showing keybinding info in render output:
+
+```typescript
+import { keyHint, appKeyHint, editorKey, rawKeyHint } from "@mariozechner/pi-coding-agent";
+
+// Editor action hint (respects user keybinding config)
+keyHint("expandTools", "to expand") // e.g., "Ctrl+O to expand"
+keyHint("selectConfirm", "to select")
+
+// Raw key hint (always shows literal key)
+rawKeyHint("Ctrl+O", "to expand")
+```
+
+
+
+Register a renderer for custom message types:
+
+```typescript
+import { Text } from "@mariozechner/pi-tui";
+
+pi.registerMessageRenderer("my-extension", (message, options, theme) => {
+ const { expanded } = options;
+ let text = theme.fg("accent", `[${message.customType}] `) + message.content;
+ if (expanded && message.details) {
+ text += "\n" + theme.fg("dim", JSON.stringify(message.details, null, 2));
+ }
+ return new Text(text, 0, 0);
+});
+
+// Send messages that use this renderer:
+pi.sendMessage({
+ customType: "my-extension", // Matches renderer name
+ content: "Status update",
+ display: true,
+ details: { foo: "bar" },
+});
+```
+
+
+
+```typescript
+import { highlightCode, getLanguageFromPath } from "@mariozechner/pi-coding-agent";
+
+const lang = getLanguageFromPath("/path/to/file.rs"); // "rust"
+const highlighted = highlightCode(code, lang, theme);
+```
+
+
+
+- Return `Text` with padding `(0, 0)` — the wrapping `Box` handles padding
+- Support `expanded` for detail on demand
+- Handle `isPartial` for streaming progress
+- Keep collapsed view compact
+- Use `\n` for multi-line content within a single `Text`
+
diff --git a/src/resources/skills/create-gsd-extension/references/custom-tools.md b/src/resources/skills/create-gsd-extension/references/custom-tools.md
new file mode 100644
index 000000000..acc1b4361
--- /dev/null
+++ b/src/resources/skills/create-gsd-extension/references/custom-tools.md
@@ -0,0 +1,183 @@
+
+Complete custom tools reference — registration, parameters, execution, output truncation, overrides, rendering, and dynamic registration.
+
+
+
+```typescript
+import { Type } from "@sinclair/typebox";
+import { StringEnum } from "@mariozechner/pi-ai";
+
+pi.registerTool({
+ name: "my_tool", // Unique identifier (snake_case)
+ label: "My Tool", // Display name in TUI
+ description: "What this does", // Full description shown to LLM
+
+ // Optional: one-liner for system prompt "Available tools" section
+ promptSnippet: "Manage project todo items",
+
+ // Optional: bullets added to system prompt "Guidelines" when tool is active
+ promptGuidelines: [
+ "Use my_tool for task management instead of file edits."
+ ],
+
+ // Parameter schema (MUST use TypeBox)
+ parameters: Type.Object({
+ action: StringEnum(["list", "add", "remove"] as const),
+ text: Type.Optional(Type.String({ description: "Item text" })),
+ id: Type.Optional(Type.Number({ description: "Item ID" })),
+ }),
+
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
+ // 1. Check cancellation
+ if (signal?.aborted) {
+ return { content: [{ type: "text", text: "Cancelled" }] };
+ }
+
+ // 2. Stream progress (optional)
+ onUpdate?.({
+ content: [{ type: "text", text: "Working..." }],
+ details: { progress: 50 },
+ });
+
+ // 3. Do the work
+ const result = await doWork(params);
+
+ // 4. Return result
+ return {
+ content: [{ type: "text", text: "Result text for LLM" }], // Sent to LLM context
+ details: { data: result }, // For rendering & state
+ };
+ },
+
+ // Optional: custom TUI rendering
+ renderCall(args, theme) { ... },
+ renderResult(result, { expanded, isPartial }, theme) { ... },
+});
+```
+
+
+
+**⚠️ MUST use `StringEnum` for string enum parameters:**
+
+```typescript
+import { StringEnum } from "@mariozechner/pi-ai";
+
+// ✅ Correct — works with all providers including Google
+action: StringEnum(["list", "add", "remove"] as const)
+
+// ❌ BROKEN with Google's API
+action: Type.Union([Type.Literal("list"), Type.Literal("add")])
+```
+
+
+
+Tools MUST truncate output to avoid context overflow. Built-in limit: 50KB / 2000 lines.
+
+```typescript
+import {
+ truncateHead, truncateTail, formatSize,
+ DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES,
+} from "@mariozechner/pi-coding-agent";
+
+async execute(toolCallId, params, signal, onUpdate, ctx) {
+ const output = await runCommand();
+ const truncation = truncateHead(output, {
+ maxLines: DEFAULT_MAX_LINES,
+ maxBytes: DEFAULT_MAX_BYTES,
+ });
+
+ let result = truncation.content;
+ if (truncation.truncated) {
+ const tempFile = writeTempFile(output);
+ result += `\n\n[Output truncated: ${truncation.outputLines}/${truncation.totalLines} lines`;
+ result += ` (${formatSize(truncation.outputBytes)}/${formatSize(truncation.totalBytes)}).`;
+ result += ` Full output: ${tempFile}]`;
+ }
+ return { content: [{ type: "text", text: result }] };
+}
+```
+
+Use `truncateHead` when beginning matters (search results, file reads). Use `truncateTail` when end matters (logs, command output).
+
+
+
+Throw to signal an error (sets `isError: true`). Returning a value never sets error flag.
+
+```typescript
+async execute(toolCallId, params) {
+ if (!isValid(params.input)) {
+ throw new Error(`Invalid input: ${params.input}`);
+ }
+ return { content: [{ type: "text", text: "OK" }], details: {} };
+}
+```
+
+
+
+Tools can be registered at any time — during load, in `session_start`, in command handlers. Available immediately without `/reload`.
+
+```typescript
+pi.on("session_start", async (_event, ctx) => {
+ pi.registerTool({ name: "dynamic_tool", ... });
+});
+```
+
+Use `pi.setActiveTools(names)` to enable/disable tools at runtime.
+
+
+
+Register a tool with the same name as a built-in (`read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`) to override it. **Must match exact result shape including `details` type.**
+
+```typescript
+import { createReadTool } from "@mariozechner/pi-coding-agent";
+
+pi.registerTool({
+ name: "read",
+ label: "Read (Logged)",
+ description: "Read file contents with logging",
+ parameters: Type.Object({
+ path: Type.String(),
+ offset: Type.Optional(Type.Number()),
+ limit: Type.Optional(Type.Number()),
+ }),
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
+ console.log(`[AUDIT] Reading: ${params.path}`);
+ const builtIn = createReadTool(ctx.cwd);
+ return builtIn.execute(toolCallId, params, signal, onUpdate);
+ },
+ // Omit renderCall/renderResult to use built-in renderer
+});
+```
+
+Start with no built-in tools: `gsd --no-tools -e ./my-extension.ts`
+
+
+
+One extension can register multiple tools with shared state:
+
+```typescript
+export default function (pi: ExtensionAPI) {
+ let connection = null;
+
+ pi.registerTool({ name: "db_connect", ... });
+ pi.registerTool({ name: "db_query", ... });
+ pi.registerTool({ name: "db_close", ... });
+
+ pi.on("session_shutdown", async () => {
+ connection?.close();
+ });
+}
+```
+
+
+
+Some models add `@` prefix to path arguments. Strip it:
+
+```typescript
+async execute(toolCallId, params, signal, onUpdate, ctx) {
+ let path = params.path;
+ if (path.startsWith("@")) path = path.slice(1);
+ // ...
+}
+```
+
diff --git a/src/resources/skills/create-gsd-extension/references/custom-ui.md b/src/resources/skills/create-gsd-extension/references/custom-ui.md
new file mode 100644
index 000000000..7eeaadc2a
--- /dev/null
+++ b/src/resources/skills/create-gsd-extension/references/custom-ui.md
@@ -0,0 +1,490 @@
+
+Complete custom UI reference — dialogs, persistent elements, custom components, overlays, custom editors, built-in components, keyboard input, performance, theming, and common mistakes.
+
+
+
+```
+┌─────────────────────────────────────────────────┐
+│ Custom Header (ctx.ui.setHeader) │
+├─────────────────────────────────────────────────┤
+│ Message Area │
+│ - User/assistant messages │
+│ - Tool calls ◄── renderCall/renderResult │
+│ - Custom messages ◄── registerMessageRenderer │
+├─────────────────────────────────────────────────┤
+│ Widgets (above editor) ◄── ctx.ui.setWidget │
+├─────────────────────────────────────────────────┤
+│ Editor ◄── ctx.ui.custom() / setEditorComponent│
+├─────────────────────────────────────────────────┤
+│ Widgets (below editor) ◄── ctx.ui.setWidget │
+├─────────────────────────────────────────────────┤
+│ Footer ◄── ctx.ui.setFooter / setStatus │
+└─────────────────────────────────────────────────┘
+ ┌─────────────────────┐
+ │ Overlay (floating) │ ◄── ctx.ui.custom({ overlay })
+ └─────────────────────┘
+```
+
+**11 ways to get UI on screen:**
+
+| Method | Blocks? | Replaces editor? |
+|--------|---------|-------------------|
+| `ctx.ui.select/confirm/input/editor` | Yes | Temporarily |
+| `ctx.ui.notify` | No | No |
+| `ctx.ui.setStatus` | No | No (footer) |
+| `ctx.ui.setWidget` | No | No |
+| `ctx.ui.setFooter` | No | No (replaces footer) |
+| `ctx.ui.setHeader` | No | No (replaces header) |
+| `ctx.ui.custom()` | Yes | Temporarily |
+| `ctx.ui.custom({overlay})` | Yes | No (renders on top) |
+| `ctx.ui.setEditorComponent` | No | Yes (permanently) |
+| `renderCall/renderResult` | No | No (inline in messages) |
+| `registerMessageRenderer` | No | No (inline in messages) |
+
+
+
+Every visual element implements:
+
+```typescript
+interface Component {
+ render(width: number): string[]; // Required — each line ≤ width visible chars
+ handleInput?(data: string): void; // Optional — receive keyboard input
+ wantsKeyRelease?: boolean; // Optional — receive key release events (Kitty protocol)
+ invalidate(): void; // Required — clear cached render state
+}
+```
+
+**Render contract:**
+- Return array of strings, one per line
+- Each string MUST NOT exceed `width` in visible characters
+- ANSI escape codes don't count toward visible width
+- **Styles are reset at end of each line** — reapply per line
+- Return `[]` for zero-height component
+
+**Invalidation contract:**
+- Clear ALL cached render output
+- Clear any pre-baked themed strings
+- Call `super.invalidate()` if extending a built-in component
+
+
+
+Blocking dialog methods on `ctx.ui`:
+
+```typescript
+const choice = await ctx.ui.select("Pick one:", ["A", "B", "C"]); // string | undefined
+const ok = await ctx.ui.confirm("Delete?", "This cannot be undone"); // boolean
+const name = await ctx.ui.input("Name:", "placeholder"); // string | undefined
+const text = await ctx.ui.editor("Edit:", "prefilled text"); // string | undefined
+
+// Timed auto-dismiss with countdown
+const ok = await ctx.ui.confirm("Proceed?", "Auto-continues in 5s", { timeout: 5000 });
+// Returns false on timeout, undefined for select/input
+
+// Manual dismissal with AbortSignal (distinguish timeout from cancel)
+const controller = new AbortController();
+const timeoutId = setTimeout(() => controller.abort(), 5000);
+const ok = await ctx.ui.confirm("Timed", "Auto-cancels in 5s", { signal: controller.signal });
+clearTimeout(timeoutId);
+if (controller.signal.aborted) { /* timed out */ }
+```
+
+
+
+```typescript
+// Footer status (multiple extensions can set independent entries)
+ctx.ui.setStatus("my-ext", "● Active");
+ctx.ui.setStatus("my-ext", undefined); // Clear
+
+// Widgets
+ctx.ui.setWidget("my-id", ["Line 1", "Line 2"]); // Above editor
+ctx.ui.setWidget("my-id", ["Below"], { placement: "belowEditor" }); // Below editor
+ctx.ui.setWidget("my-id", (_tui, theme) => ({ // Themed
+ render: () => [theme.fg("accent", "Styled")],
+ invalidate: () => {},
+}));
+ctx.ui.setWidget("my-id", undefined); // Clear
+
+// Working message during streaming
+ctx.ui.setWorkingMessage("Analyzing code...");
+ctx.ui.setWorkingMessage(); // Restore default
+
+// Custom footer (full replacement)
+ctx.ui.setFooter((tui, theme, footerData) => ({
+ render(width) {
+ const branch = footerData.getGitBranch(); // Only available here
+ const statuses = footerData.getExtensionStatuses(); // All setStatus values
+ return [truncateToWidth(`${branch} | model`, width)];
+ },
+ invalidate() {},
+ dispose: footerData.onBranchChange(() => tui.requestRender()), // Reactive
+}));
+ctx.ui.setFooter(undefined); // Restore default
+
+// Custom header
+ctx.ui.setHeader((tui, theme) => ({
+ render(width) { return [theme.fg("accent", theme.bold("My Header"))]; },
+ invalidate() {},
+}));
+
+// Editor control
+ctx.ui.setEditorText("Prefill");
+const current = ctx.ui.getEditorText();
+ctx.ui.pasteToEditor("pasted content"); // Triggers paste handling
+
+// Tool expansion
+ctx.ui.setToolsExpanded(true);
+const expanded = ctx.ui.getToolsExpanded();
+
+// Theme management
+const themes = ctx.ui.getAllThemes();
+ctx.ui.setTheme("light");
+ctx.ui.theme.fg("accent", "text"); // Access current theme
+```
+
+
+
+`ctx.ui.custom()` temporarily replaces the editor. Returns a value when `done()` is called.
+
+**Factory callback args:**
+
+| Argument | Type | Purpose |
+|----------|------|---------|
+| `tui` | `TUI` | `tui.requestRender()` triggers re-render after state changes |
+| `theme` | `Theme` | Current theme for styling |
+| `keybindings` | `KeybindingsManager` | App keybinding config |
+| `done` | `(value: T) => void` | Close component and return value |
+
+**Inline pattern:**
+```typescript
+const result = await ctx.ui.custom((tui, theme, keybindings, done) => ({
+ render(width: number): string[] {
+ return [truncateToWidth("Press Enter to confirm, Escape to cancel", width)];
+ },
+ handleInput(data: string) {
+ if (matchesKey(data, Key.enter)) done("confirmed");
+ if (matchesKey(data, Key.escape)) done(null);
+ },
+ invalidate() {},
+}));
+```
+
+**Class-based pattern (recommended for complex UI):**
+```typescript
+class MyComponent {
+ private selected = 0;
+ private cachedWidth?: number;
+ private cachedLines?: string[];
+
+ constructor(
+ private tui: { requestRender: () => void },
+ private theme: Theme,
+ private items: string[],
+ private done: (value: string | null) => void,
+ ) {}
+
+ handleInput(data: string) {
+ if (matchesKey(data, Key.up) && this.selected > 0) this.selected--;
+ else if (matchesKey(data, Key.down) && this.selected < this.items.length - 1) this.selected++;
+ else if (matchesKey(data, Key.enter)) { this.done(this.items[this.selected]); return; }
+ else if (matchesKey(data, Key.escape)) { this.done(null); return; }
+ else return;
+ this.invalidate();
+ this.tui.requestRender();
+ }
+
+ render(width: number): string[] {
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
+ this.cachedLines = this.items.map((item, i) =>
+ truncateToWidth((i === this.selected ? "> " : " ") + item, width)
+ );
+ this.cachedWidth = width;
+ return this.cachedLines;
+ }
+
+ invalidate() { this.cachedWidth = undefined; this.cachedLines = undefined; }
+}
+
+const result = await ctx.ui.custom((tui, theme, _kb, done) =>
+ new MyComponent(tui, theme, ["A", "B", "C"], done)
+);
+```
+
+**Composing with built-in components:**
+```typescript
+const result = await ctx.ui.custom((tui, theme, _kb, done) => {
+ const container = new Container();
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
+ container.addChild(new Text(theme.fg("accent", theme.bold("Title")), 1, 0));
+
+ const selectList = new SelectList(items, 10, {
+ selectedPrefix: (t) => theme.fg("accent", t),
+ selectedText: (t) => theme.fg("accent", t),
+ description: (t) => theme.fg("muted", t),
+ scrollInfo: (t) => theme.fg("dim", t),
+ noMatch: (t) => theme.fg("warning", t),
+ });
+ selectList.onSelect = (item) => done(item.value);
+ selectList.onCancel = () => done(null);
+ container.addChild(selectList);
+
+ return {
+ render: (w) => container.render(w),
+ invalidate: () => container.invalidate(),
+ handleInput: (data) => { selectList.handleInput(data); tui.requestRender(); },
+ };
+});
+```
+
+
+
+Floating modals rendered on top of everything:
+
+```typescript
+const result = await ctx.ui.custom(
+ (tui, theme, _kb, done) => new MyDialog({ onClose: done }),
+ {
+ overlay: true,
+ overlayOptions: {
+ anchor: "center", // 9 positions (see below)
+ width: "50%", // number = columns, string = percentage
+ minWidth: 40,
+ maxHeight: "80%",
+ margin: 2, // All sides, or { top, right, bottom, left }
+ offsetX: 0, offsetY: 0, // Fine-tune position
+ visible: (w, h) => w >= 80, // Hide on narrow terminals
+ },
+ onHandle: (handle) => {
+ // handle.setHidden(true/false) — temporarily hide
+ // handle.hide() — permanently remove
+ },
+ }
+);
+```
+
+**Anchor positions:**
+```
+top-left top-center top-right
+left-center center right-center
+bottom-left bottom-center bottom-right
+```
+
+**Stacked overlays:** Multiple overlays stack (newest on top). Closing one gives focus to the one below.
+
+**⚠️ Overlay lifecycle:** Components are disposed when closed. Never reuse references — create fresh instances each time.
+
+
+
+Replace the main input editor permanently:
+
+```typescript
+import { CustomEditor } from "@mariozechner/pi-coding-agent";
+
+class VimEditor extends CustomEditor {
+ private mode: "normal" | "insert" = "insert";
+
+ handleInput(data: string): void {
+ if (matchesKey(data, "escape") && this.mode === "insert") {
+ this.mode = "normal"; return;
+ }
+ if (this.mode === "insert") { super.handleInput(data); return; }
+ switch (data) {
+ case "i": this.mode = "insert"; return;
+ case "h": super.handleInput("\x1b[D"); return; // Left
+ case "j": super.handleInput("\x1b[B"); return; // Down
+ case "k": super.handleInput("\x1b[A"); return; // Up
+ case "l": super.handleInput("\x1b[C"); return; // Right
+ }
+ if (data.length === 1 && data.charCodeAt(0) >= 32) return; // Block printable in normal
+ super.handleInput(data);
+ }
+}
+
+ctx.ui.setEditorComponent((_tui, theme, keybindings) => new VimEditor(theme, keybindings));
+ctx.ui.setEditorComponent(undefined); // Restore default
+```
+
+**Critical:** Extend `CustomEditor` (NOT `Editor`) to get app keybindings (escape to abort, ctrl+d, model switching).
+
+
+
+**From `@mariozechner/pi-tui`:**
+
+| Component | Constructor | Purpose |
+|-----------|-------------|---------|
+| `Text` | `new Text(content, paddingX, paddingY, bgFn?)` | Multi-line text with word wrap |
+| `Box` | `new Box(paddingX, paddingY, bgFn)` | Container with padding+background, `.addChild()` |
+| `Container` | `new Container()` | Vertical stack, `.addChild()`, `.removeChild()`, `.clear()` |
+| `Spacer` | `new Spacer(lines)` | Empty vertical space |
+| `Markdown` | `new Markdown(content, padX, padY, getMarkdownTheme())` | Rendered markdown with syntax highlighting |
+| `Image` | `new Image(base64, mimeType, theme, opts?)` | Image rendering (Kitty, iTerm2) |
+| `SelectList` | `new SelectList(items, maxVisible, themeOpts)` | Interactive selection with search and scrolling |
+| `SettingsList` | `new SettingsList(items, maxVisible, theme, onChange, onClose, opts?)` | Toggle settings with left/right arrows |
+| `Input` | `new Input()` | Text input field |
+| `Editor` | `new Editor(tui, editorTheme)` | Multi-line editor with undo |
+
+**SelectList usage:**
+```typescript
+const items: SelectItem[] = [
+ { value: "opt1", label: "Option 1", description: "First option" },
+ { value: "opt2", label: "Option 2" },
+];
+const selectList = new SelectList(items, 10, {
+ selectedPrefix: (t) => theme.fg("accent", t),
+ selectedText: (t) => theme.fg("accent", t),
+ description: (t) => theme.fg("muted", t),
+ scrollInfo: (t) => theme.fg("dim", t),
+ noMatch: (t) => theme.fg("warning", t),
+});
+selectList.onSelect = (item) => { /* item.value */ };
+selectList.onCancel = () => { /* escape pressed */ };
+```
+
+**SettingsList usage:**
+```typescript
+const items: SettingItem[] = [
+ { id: "verbose", label: "Verbose mode", currentValue: "off", values: ["on", "off"] },
+ { id: "theme", label: "Theme", currentValue: "dark", values: ["dark", "light", "auto"] },
+];
+const settings = new SettingsList(items, 15, getSettingsListTheme(),
+ (id, newValue) => { /* setting changed */ },
+ () => { /* close requested */ },
+ { enableSearch: true },
+);
+```
+
+**From `@mariozechner/pi-coding-agent`:**
+
+| Component | Constructor | Purpose |
+|-----------|-------------|---------|
+| `DynamicBorder` | `new DynamicBorder((s: string) => theme.fg("accent", s))` | Border line |
+| `BorderedLoader` | — | Spinner with cancel support |
+| `CustomEditor` | `new CustomEditor(theme, keybindings)` | Base class for custom editors |
+
+
+
+```typescript
+import { matchesKey, Key } from "@mariozechner/pi-tui";
+
+handleInput(data: string) {
+ // Basic keys
+ if (matchesKey(data, Key.up)) {}
+ if (matchesKey(data, Key.down)) {}
+ if (matchesKey(data, Key.enter)) {}
+ if (matchesKey(data, Key.escape)) {}
+ if (matchesKey(data, Key.tab)) {}
+ if (matchesKey(data, Key.space)) {}
+ if (matchesKey(data, Key.backspace)) {}
+ if (matchesKey(data, Key.home)) {}
+ if (matchesKey(data, Key.end)) {}
+
+ // With modifiers
+ if (matchesKey(data, Key.ctrl("c"))) {}
+ if (matchesKey(data, Key.shift("tab"))) {}
+ if (matchesKey(data, Key.alt("left"))) {}
+ if (matchesKey(data, Key.ctrlShift("p"))) {}
+
+ // String format also works: "enter", "ctrl+c", "shift+tab"
+
+ // Printable character detection
+ if (data.length === 1 && data.charCodeAt(0) >= 32) {
+ // Letter, number, symbol
+ }
+}
+```
+
+**handleInput contract:**
+1. Check for your keys
+2. Update state
+3. Call `this.invalidate()` if render output changes
+4. Call `tui.requestRender()` to trigger re-render
+
+
+
+**Cardinal rule: each line from render() must not exceed `width` visible characters.**
+
+```typescript
+import { visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
+
+visibleWidth("\x1b[32mHello\x1b[0m"); // Returns 5 (ignores ANSI codes)
+truncateToWidth("Very long text here", 10); // "Very lo..."
+truncateToWidth("Very long text here", 10, ""); // "Very long " (no ellipsis)
+wrapTextWithAnsi("\x1b[32mLong green text\x1b[0m", 10); // Word wrap preserving ANSI
+```
+
+If lines exceed `width`, terminal wraps cause visual corruption.
+
+
+
+Always cache render output:
+
+```typescript
+class CachedComponent {
+ private cachedWidth?: number;
+ private cachedLines?: string[];
+
+ render(width: number): string[] {
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
+ const lines = this.computeLines(width);
+ this.cachedWidth = width;
+ this.cachedLines = lines;
+ return lines;
+ }
+
+ invalidate() { this.cachedWidth = undefined; this.cachedLines = undefined; }
+}
+```
+
+**Update cycle:** State changes → `invalidate()` → `tui.requestRender()` → `render(width)` called
+
+**Game loop pattern** (real-time updates):
+```typescript
+this.interval = setInterval(() => {
+ this.tick();
+ this.version++;
+ this.tui.requestRender();
+}, 100); // 10 FPS
+
+// Clean up in dispose()
+clearInterval(this.interval);
+```
+
+
+
+Always use theme from callback params, never import directly.
+
+**All foreground colors:**
+
+| Category | Colors |
+|----------|--------|
+| General | `text`, `accent`, `muted`, `dim` |
+| Status | `success`, `error`, `warning` |
+| Borders | `border`, `borderAccent`, `borderMuted` |
+| Messages | `userMessageText`, `customMessageText`, `customMessageLabel` |
+| Tools | `toolTitle`, `toolOutput` |
+| Diffs | `toolDiffAdded`, `toolDiffRemoved`, `toolDiffContext` |
+| Markdown | `mdHeading`, `mdLink`, `mdLinkUrl`, `mdCode`, `mdCodeBlock`, `mdCodeBlockBorder`, `mdQuote`, `mdQuoteBorder`, `mdHr`, `mdListBullet` |
+| Syntax | `syntaxComment`, `syntaxKeyword`, `syntaxFunction`, `syntaxVariable`, `syntaxString`, `syntaxNumber`, `syntaxType`, `syntaxOperator`, `syntaxPunctuation` |
+| Thinking | `thinkingOff`, `thinkingMinimal`, `thinkingLow`, `thinkingMedium`, `thinkingHigh`, `thinkingXhigh` |
+
+**All background colors:** `selectedBg`, `userMessageBg`, `customMessageBg`, `toolPendingBg`, `toolSuccessBg`, `toolErrorBg`
+
+**Syntax highlighting:**
+```typescript
+import { highlightCode, getLanguageFromPath } from "@mariozechner/pi-coding-agent";
+const lang = getLanguageFromPath("/file.rs"); // "rust"
+const highlighted = highlightCode(code, lang, theme);
+```
+
+
+
+1. **Lines exceed width** → Visual corruption. Use `truncateToWidth()` on every line.
+2. **Forgetting `tui.requestRender()`** → UI doesn't update. Call after invalidate().
+3. **Importing theme directly** → Wrong colors after theme switch. Use theme from callback.
+4. **Not typing DynamicBorder param** → `new DynamicBorder((s: string) => theme.fg("accent", s))`.
+5. **Reusing disposed overlay components** → Create fresh instances each time.
+6. **Styles bleeding across lines** → TUI resets per line. Reapply styles, or use `wrapTextWithAnsi()`.
+7. **Not implementing invalidate()** → Theme changes don't take effect.
+8. **Forgetting super.invalidate()** → `override invalidate() { super.invalidate(); /* cleanup */ }`
+9. **Timer not cleaned up** → Call `clearInterval` before `done()`.
+10. **Using ctx.ui in non-interactive mode** → Check `ctx.hasUI` first.
+
diff --git a/src/resources/skills/create-gsd-extension/references/events-reference.md b/src/resources/skills/create-gsd-extension/references/events-reference.md
new file mode 100644
index 000000000..82028f5de
--- /dev/null
+++ b/src/resources/skills/create-gsd-extension/references/events-reference.md
@@ -0,0 +1,126 @@
+
+Complete event reference with handler signatures, return types, and type narrowing utilities.
+
+
+
+
+**Session events:** `session_start`, `session_before_switch`, `session_switch`, `session_before_fork`, `session_fork`, `session_before_compact`, `session_compact`, `session_before_tree`, `session_tree`, `session_shutdown`
+
+**Agent events:** `before_agent_start`, `agent_start`, `agent_end`, `turn_start`, `turn_end`, `context`, `before_provider_request`, `message_start`, `message_update`, `message_end`
+
+**Tool events:** `tool_call`, `tool_execution_start`, `tool_execution_update`, `tool_execution_end`, `tool_result`
+
+**Input events:** `input`
+
+**Model events:** `model_select`
+
+**User bash events:** `user_bash`
+
+**Special:** `session_directory` (CLI startup only, no `ctx` — receives only event)
+
+
+
+
+```typescript
+pi.on("event_name", async (event, ctx: ExtensionContext) => {
+ // event — typed payload for this event
+ // ctx — access to UI, session, model, control flow
+ // Return undefined for no action, or a typed response
+});
+```
+
+
+
+
+**before_agent_start** — Fired after user prompt, before agent loop. Primary hook for context injection and system prompt modification.
+```typescript
+pi.on("before_agent_start", async (event, ctx) => {
+ // event.prompt — user's prompt text
+ // event.images — attached images
+ // event.systemPrompt — current system prompt
+ return {
+ message: { customType: "my-ext", content: "Extra context", display: true },
+ systemPrompt: event.systemPrompt + "\n\nExtra instructions...",
+ };
+});
+```
+
+**tool_call** — Fired before tool executes. Can block.
+```typescript
+import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
+
+pi.on("tool_call", async (event, ctx) => {
+ if (isToolCallEventType("bash", event)) {
+ // event.input is typed as { command: string; timeout?: number }
+ if (event.input.command.includes("rm -rf")) {
+ return { block: true, reason: "Dangerous command" };
+ }
+ }
+});
+```
+
+**tool_result** — Fired after tool executes. Can modify result. Handlers chain like middleware.
+```typescript
+import { isBashToolResult } from "@mariozechner/pi-coding-agent";
+
+pi.on("tool_result", async (event, ctx) => {
+ if (isBashToolResult(event)) {
+ // event.details is typed as BashToolDetails
+ }
+ // Return partial patch: { content, details, isError }
+ // Omitted fields keep current values
+});
+```
+
+**context** — Fired before each LLM call. Modify messages non-destructively.
+```typescript
+pi.on("context", async (event, ctx) => {
+ // event.messages is a deep copy — safe to modify
+ const filtered = event.messages.filter(m => !shouldPrune(m));
+ return { messages: filtered };
+});
+```
+
+**input** — Fired when user input is received, before skill/template expansion.
+```typescript
+pi.on("input", async (event, ctx) => {
+ // event.text — raw input
+ // event.source — "interactive", "rpc", or "extension"
+ if (event.text.startsWith("?quick "))
+ return { action: "transform", text: `Respond briefly: ${event.text.slice(7)}` };
+ return { action: "continue" };
+});
+```
+
+**model_select** — Fired when model changes.
+```typescript
+pi.on("model_select", async (event, ctx) => {
+ // event.model, event.previousModel, event.source ("set"|"cycle"|"restore")
+});
+```
+
+
+
+
+Built-in type guards for tool events:
+
+```typescript
+import { isToolCallEventType, isBashToolResult } from "@mariozechner/pi-coding-agent";
+
+// Tool calls — narrows event.input type
+if (isToolCallEventType("bash", event)) { /* event.input: { command, timeout? } */ }
+if (isToolCallEventType("read", event)) { /* event.input: { path, offset?, limit? } */ }
+if (isToolCallEventType("write", event)) { /* event.input: { path, content } */ }
+if (isToolCallEventType("edit", event)) { /* event.input: { path, oldText, newText } */ }
+
+// Tool results — narrows event.details type
+if (isBashToolResult(event)) { /* event.details: BashToolDetails */ }
+```
+
+For custom tools, export your input type and use explicit type params:
+```typescript
+if (isToolCallEventType<"my_tool", MyToolInput>("my_tool", event)) {
+ event.input.action; // typed
+}
+```
+
diff --git a/src/resources/skills/create-gsd-extension/references/extension-lifecycle.md b/src/resources/skills/create-gsd-extension/references/extension-lifecycle.md
new file mode 100644
index 000000000..d6ab71c42
--- /dev/null
+++ b/src/resources/skills/create-gsd-extension/references/extension-lifecycle.md
@@ -0,0 +1,64 @@
+
+The extension lifecycle from load to shutdown, including the full event flow.
+
+
+
+Extensions load when GSD starts (or on `/reload`). The default export function runs synchronously — subscribe to events and register tools/commands during this call.
+
+```
+GSD starts
+ └─► Extension default function runs
+ ├── pi.on("event", handler) ← Subscribe
+ ├── pi.registerTool({...}) ← Register tools
+ ├── pi.registerCommand(...) ← Register commands
+ └── pi.registerShortcut(...) ← Register shortcuts
+ └─► session_start fires
+```
+
+
+
+Full event flow per user prompt:
+
+```
+user sends prompt
+ ├─► Extension commands checked (bypass if match)
+ ├─► input event (can intercept/transform/handle)
+ ├─► Skill/template expansion
+ ├─► before_agent_start (inject message, modify system prompt)
+ ├─► agent_start
+ │
+ │ ┌── Turn loop (repeats while LLM calls tools) ──┐
+ │ │ turn_start │
+ │ │ context (can modify messages sent to LLM) │
+ │ │ before_provider_request (inspect/replace payload)│
+ │ │ LLM responds → may call tools: │
+ │ │ tool_call (can BLOCK) │
+ │ │ tool_execution_start/update/end │
+ │ │ tool_result (can MODIFY) │
+ │ │ turn_end │
+ │ └────────────────────────────────────────────────┘
+ │
+ └─► agent_end
+```
+
+
+
+| Event | When | Can Return |
+|-------|------|------------|
+| `session_start` | Session loads | — |
+| `session_before_switch` | Before `/new` or `/resume` | `{ cancel: true }` |
+| `session_switch` | After switch | — |
+| `session_before_fork` | Before `/fork` | `{ cancel: true }`, `{ skipConversationRestore: true }` |
+| `session_fork` | After fork | — |
+| `session_before_compact` | Before compaction | `{ cancel: true }`, `{ compaction: {...} }` |
+| `session_compact` | After compaction | — |
+| `session_shutdown` | On exit | — |
+
+
+
+Extensions in auto-discovered locations hot-reload with `/reload`:
+- `session_shutdown` fires for old runtime
+- Resources re-scanned
+- `session_start` fires for new runtime
+- Code after `await ctx.reload()` still runs from the pre-reload version — treat as terminal
+
diff --git a/src/resources/skills/create-gsd-extension/references/extensionapi-reference.md b/src/resources/skills/create-gsd-extension/references/extensionapi-reference.md
new file mode 100644
index 000000000..7e5f458df
--- /dev/null
+++ b/src/resources/skills/create-gsd-extension/references/extensionapi-reference.md
@@ -0,0 +1,75 @@
+
+ExtensionAPI methods — the `pi` object received in the default export function.
+
+
+
+| Method | Purpose |
+|--------|---------|
+| `pi.on(event, handler)` | Subscribe to events |
+| `pi.registerTool(definition)` | Register LLM-callable tool |
+| `pi.registerCommand(name, options)` | Register `/command` |
+| `pi.registerShortcut(key, options)` | Register keyboard shortcut |
+| `pi.registerFlag(name, options)` | Register CLI flag |
+| `pi.registerMessageRenderer(customType, renderer)` | Custom message rendering |
+| `pi.registerProvider(name, config)` | Register/override model provider |
+| `pi.unregisterProvider(name)` | Remove a provider |
+
+
+
+| Method | Purpose |
+|--------|---------|
+| `pi.sendMessage(message, options?)` | Inject custom message into session |
+| `pi.sendUserMessage(content, options?)` | Send user message (triggers turn) |
+
+**Delivery modes for `sendMessage`:**
+- `"steer"` (default) — Interrupts streaming after current tool
+- `"followUp"` — Waits for agent to finish all tools
+- `"nextTurn"` — Queued for next user prompt
+
+```typescript
+pi.sendMessage({
+ customType: "my-extension",
+ content: "Additional context",
+ display: true,
+ details: { ... },
+}, { deliverAs: "steer", triggerTurn: true });
+```
+
+
+
+| Method | Purpose |
+|--------|---------|
+| `pi.appendEntry(customType, data?)` | Persist state (NOT sent to LLM) |
+| `pi.setSessionName(name)` | Set session display name |
+| `pi.getSessionName()` | Get session name |
+| `pi.setLabel(entryId, label)` | Bookmark entry for `/tree` |
+
+
+
+```typescript
+const active = pi.getActiveTools(); // ["read", "bash", "edit", "write"]
+const all = pi.getAllTools(); // [{ name, description }, ...]
+pi.setActiveTools(["read", "bash"]); // Enable/disable tools
+```
+
+
+
+```typescript
+const model = ctx.modelRegistry.find("anthropic", "claude-sonnet-4-5");
+if (model) {
+ const success = await pi.setModel(model); // Returns false if no API key
+}
+
+pi.getThinkingLevel(); // "off" | "minimal" | "low" | "medium" | "high" | "xhigh"
+pi.setThinkingLevel("high");
+```
+
+
+
+| Method | Purpose |
+|--------|---------|
+| `pi.exec(cmd, args, opts?)` | Shell command (prefer over child_process) |
+| `pi.events` | Shared event bus for inter-extension communication |
+| `pi.getFlag(name)` | Get CLI flag value |
+| `pi.getCommands()` | All available slash commands |
+
diff --git a/src/resources/skills/create-gsd-extension/references/extensioncontext-reference.md b/src/resources/skills/create-gsd-extension/references/extensioncontext-reference.md
new file mode 100644
index 000000000..ab04ad711
--- /dev/null
+++ b/src/resources/skills/create-gsd-extension/references/extensioncontext-reference.md
@@ -0,0 +1,53 @@
+
+ExtensionContext (`ctx`) — available in all event handlers (except `session_directory`).
+
+
+
+**Dialogs (blocking — wait for user response):**
+```typescript
+const choice = await ctx.ui.select("Pick one:", ["A", "B", "C"]);
+const ok = await ctx.ui.confirm("Delete?", "This cannot be undone");
+const name = await ctx.ui.input("Name:", "placeholder");
+const text = await ctx.ui.editor("Edit:", "prefilled text");
+
+// Timed dialog — auto-dismiss after timeout
+const ok = await ctx.ui.confirm("Auto-confirm?", "Proceeds in 5s", { timeout: 5000 });
+```
+
+**Non-blocking UI:**
+```typescript
+ctx.ui.notify("Done!", "info"); // Toast: "info" | "warning" | "error"
+ctx.ui.setStatus("my-ext", "● Active"); // Footer status
+ctx.ui.setStatus("my-ext", undefined); // Clear
+ctx.ui.setWidget("my-id", ["Line 1", "Line 2"]); // Widget above editor
+ctx.ui.setWidget("my-id", ["Below!"], { placement: "belowEditor" });
+ctx.ui.setTitle("gsd - my project"); // Terminal title
+ctx.ui.setEditorText("Prefill"); // Set editor content
+ctx.ui.setWorkingMessage("Analyzing..."); // Working message during streaming
+ctx.ui.setToolsExpanded(true); // Expand tool output
+```
+
+
+
+| Property/Method | Purpose |
+|----------------|---------|
+| `ctx.hasUI` | `false` in print/JSON mode — check before dialogs |
+| `ctx.cwd` | Current working directory |
+| `ctx.sessionManager` | Read-only session state |
+| `ctx.modelRegistry` / `ctx.model` | Model access |
+| `ctx.isIdle()` / `ctx.abort()` / `ctx.hasPendingMessages()` | Agent state |
+| `ctx.shutdown()` | Request graceful exit (deferred until idle) |
+| `ctx.getContextUsage()` | Current context token usage |
+| `ctx.compact(options?)` | Trigger compaction |
+| `ctx.getSystemPrompt()` | Current effective system prompt |
+
+
+
+```typescript
+ctx.sessionManager.getEntries() // All entries
+ctx.sessionManager.getBranch() // Current branch
+ctx.sessionManager.getLeafId() // Current leaf entry ID
+ctx.sessionManager.getSessionFile() // Session JSONL path
+ctx.sessionManager.getLabel(entryId) // Entry label
+```
+
diff --git a/src/resources/skills/create-gsd-extension/references/key-rules-gotchas.md b/src/resources/skills/create-gsd-extension/references/key-rules-gotchas.md
new file mode 100644
index 000000000..75f73f2c8
--- /dev/null
+++ b/src/resources/skills/create-gsd-extension/references/key-rules-gotchas.md
@@ -0,0 +1,36 @@
+
+Non-negotiable rules and common gotchas when building GSD extensions.
+
+
+
+1. **Use `StringEnum` for string enums** — `Type.Union`/`Type.Literal` breaks Google's API.
+2. **Truncate tool output** — Large output causes context overflow, compaction failures, degraded performance. Limit: 50KB / 2000 lines.
+3. **Use theme from callback** — Don't import theme directly. Use the `theme` parameter from `ctx.ui.custom()` or render functions.
+4. **`DynamicBorder` color param** — Type as `(s: string) => theme.fg("accent", s)`.
+5. **Call `tui.requestRender()` after state changes** in `handleInput`.
+6. **Return `{ render, invalidate, handleInput }`** from custom components.
+7. **Lines must not exceed `width`** in `render()` — use `truncateToWidth()`.
+8. **Session control methods ONLY in commands** — `waitForIdle()`, `newSession()`, `fork()`, `navigateTree()`, `reload()` will **deadlock** in event handlers.
+9. **Strip leading `@` from path arguments** — some models add it.
+10. **Store state in tool result `details`** for proper branching support.
+
+
+
+- Rebuild component on `invalidate()` when pre-baking theme colors
+- Check `signal?.aborted` in long-running tool executions
+- Use `pi.exec()` instead of `child_process` for shell commands
+- Overlay components are **disposed when closed** — create fresh instances each time
+- Treat `ctx.reload()` as terminal — code after runs from pre-reload version
+- Check `ctx.hasUI` before dialog methods (false in print/JSON mode)
+- Extension errors are logged but don't crash GSD — tool_call handler errors fail-safe (block the tool)
+
+
+
+**GSD extension paths:**
+- Global: `~/.gsd/agent/extensions/*.ts`
+- Global (subdir): `~/.gsd/agent/extensions/*/index.ts`
+- Project-local: `.gsd/extensions/*.ts`
+- Project-local (subdir): `.gsd/extensions/*/index.ts`
+
+The upstream pi docs reference `~/.pi` paths — GSD uses `~/.gsd` everywhere instead.
+
diff --git a/src/resources/skills/create-gsd-extension/references/mode-behavior.md b/src/resources/skills/create-gsd-extension/references/mode-behavior.md
new file mode 100644
index 000000000..3e8db5822
--- /dev/null
+++ b/src/resources/skills/create-gsd-extension/references/mode-behavior.md
@@ -0,0 +1,32 @@
+
+Mode behavior determines which UI methods work. Extensions may run in non-interactive modes where dialogs are unavailable.
+
+
+
+| Mode | UI Methods | Notes |
+|------|-----------|-------|
+| **Interactive** (default) | Full TUI | Normal operation — all UI works |
+| **RPC** (`--mode rpc`) | JSON protocol | Host handles UI, dialogs work via sub-protocol |
+| **JSON** (`--mode json`) | No-op | Event stream to stdout, no UI |
+| **Print** (`-p`) | No-op | Extensions run but can't prompt users |
+
+
+
+**Always check `ctx.hasUI`** before calling dialog methods:
+
+```typescript
+if (ctx.hasUI) {
+ const ok = await ctx.ui.confirm("Delete?", "Sure?");
+ if (!ok) return;
+} else {
+ // Default behavior for non-interactive mode
+ // Or just proceed without confirmation
+}
+```
+
+`ctx.hasUI` is `false` in print mode (`-p`) and JSON mode. `true` in interactive and RPC mode.
+
+
+
+Non-blocking methods (`notify`, `setStatus`, `setWidget`, `setTitle`, `setEditorText`) are safe in all modes — they're no-ops when no UI is available.
+
diff --git a/src/resources/skills/create-gsd-extension/references/model-provider-management.md b/src/resources/skills/create-gsd-extension/references/model-provider-management.md
new file mode 100644
index 000000000..c57d5fb22
--- /dev/null
+++ b/src/resources/skills/create-gsd-extension/references/model-provider-management.md
@@ -0,0 +1,89 @@
+
+Model and provider management — switching models, registering custom providers with OAuth, and reacting to model changes.
+
+
+
+```typescript
+const model = ctx.modelRegistry.find("anthropic", "claude-sonnet-4-5");
+if (model) {
+ const success = await pi.setModel(model);
+ if (!success) ctx.ui.notify("No API key for this model", "error");
+}
+
+// Thinking level
+pi.getThinkingLevel(); // "off" | "minimal" | "low" | "medium" | "high" | "xhigh"
+pi.setThinkingLevel("high"); // Clamped to model capabilities
+```
+
+
+
+```typescript
+pi.registerProvider("my-proxy", {
+ baseUrl: "https://proxy.example.com",
+ apiKey: "PROXY_API_KEY", // Env var name or literal
+ api: "anthropic-messages", // or "openai-completions", "openai-responses"
+ headers: { "X-Custom": "value" }, // Optional custom headers
+ authHeader: true, // Auto-add Authorization: Bearer header
+ models: [
+ {
+ id: "claude-sonnet-4-20250514",
+ name: "Claude 4 Sonnet (proxy)",
+ reasoning: false,
+ input: ["text", "image"],
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 200000,
+ maxTokens: 16384,
+ }
+ ],
+});
+
+// Override just baseUrl for an existing provider (keeps all models)
+pi.registerProvider("anthropic", {
+ baseUrl: "https://proxy.example.com",
+});
+
+// Remove a provider (restores any overridden built-in models)
+pi.unregisterProvider("my-proxy");
+```
+
+Takes effect immediately after initial load phase — no `/reload` required.
+
+
+
+Register a provider with OAuth support for `/login`:
+
+```typescript
+pi.registerProvider("corporate-ai", {
+ baseUrl: "https://ai.corp.com",
+ api: "openai-responses",
+ models: [/* ... */],
+ oauth: {
+ name: "Corporate AI (SSO)",
+ async login(callbacks) {
+ callbacks.onAuth({ url: "https://sso.corp.com/..." });
+ const code = await callbacks.onPrompt({ message: "Enter code:" });
+ return { refresh: code, access: code, expires: Date.now() + 3600000 };
+ },
+ async refreshToken(credentials) {
+ return credentials; // Refresh logic
+ },
+ getApiKey(credentials) {
+ return credentials.access;
+ },
+ },
+});
+```
+
+
+
+React to model changes:
+
+```typescript
+pi.on("model_select", async (event, ctx) => {
+ // event.model — newly selected model
+ // event.previousModel — previous model (undefined if first)
+ // event.source — "set" | "cycle" | "restore"
+ ctx.ui.setStatus("model", `${event.model.provider}/${event.model.id}`);
+});
+```
+
diff --git a/src/resources/skills/create-gsd-extension/references/packaging-distribution.md b/src/resources/skills/create-gsd-extension/references/packaging-distribution.md
new file mode 100644
index 000000000..a3d603182
--- /dev/null
+++ b/src/resources/skills/create-gsd-extension/references/packaging-distribution.md
@@ -0,0 +1,55 @@
+
+Packaging extensions for distribution via npm, git, or local paths. Creating GSD/pi packages.
+
+
+
+Add a `pi` manifest to `package.json`:
+
+```json
+{
+ "name": "my-gsd-package",
+ "keywords": ["pi-package"],
+ "pi": {
+ "extensions": ["./extensions"],
+ "skills": ["./skills"],
+ "prompts": ["./prompts"],
+ "themes": ["./themes"]
+ }
+}
+```
+
+
+
+```bash
+gsd install npm:@foo/bar@1.0.0
+gsd install git:github.com/user/repo@v1
+gsd install ./local/path
+
+# Try without installing:
+gsd -e npm:@foo/bar
+```
+
+
+
+If no `pi` manifest exists, auto-discovers:
+- `extensions/` → `.ts` and `.js` files
+- `skills/` → `SKILL.md` folders
+- `prompts/` → `.md` files
+- `themes/` → `.json` files
+
+
+
+- List `@mariozechner/pi-ai`, `@mariozechner/pi-coding-agent`, `@mariozechner/pi-tui`, `@sinclair/typebox` in `peerDependencies` with `"*"` — they're bundled by the runtime.
+- Other npm deps go in `dependencies`. The runtime runs `npm install` on package installation.
+
+
+
+```json
+{
+ "pi": {
+ "video": "https://example.com/demo.mp4",
+ "image": "https://example.com/screenshot.png"
+ }
+}
+```
+
diff --git a/src/resources/skills/create-gsd-extension/references/remote-execution-overrides.md b/src/resources/skills/create-gsd-extension/references/remote-execution-overrides.md
new file mode 100644
index 000000000..1945fb80e
--- /dev/null
+++ b/src/resources/skills/create-gsd-extension/references/remote-execution-overrides.md
@@ -0,0 +1,90 @@
+
+Remote execution via pluggable operations, spawnHook for bash, and tool override patterns.
+
+
+
+Built-in tools support pluggable operations for SSH, containers, etc.:
+
+```typescript
+import { createReadTool, createBashTool, createWriteTool } from "@mariozechner/pi-coding-agent";
+
+// Create tool with custom remote operations
+const remoteBash = createBashTool(cwd, {
+ operations: {
+ execute: (cmd) => sshExec(remote, cmd),
+ },
+});
+```
+
+**Operations interfaces:** `ReadOperations`, `WriteOperations`, `EditOperations`, `BashOperations`, `LsOperations`, `GrepOperations`, `FindOperations`
+
+
+
+The bash tool supports a `spawnHook` to modify commands before execution:
+
+```typescript
+const bashTool = createBashTool(cwd, {
+ spawnHook: ({ command, cwd, env }) => ({
+ command: `source ~/.profile\n${command}`,
+ cwd: `/mnt/sandbox${cwd}`,
+ env: { ...env, CI: "1" },
+ }),
+});
+```
+
+
+
+Full SSH pattern with flag-based switching:
+
+```typescript
+import { createBashTool, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
+
+export default function (pi: ExtensionAPI) {
+ pi.registerFlag("ssh", { description: "SSH target", type: "string" });
+
+ const localBash = createBashTool(process.cwd());
+
+ pi.registerTool({
+ ...localBash,
+ async execute(id, params, signal, onUpdate, ctx) {
+ const sshTarget = pi.getFlag("--ssh");
+ if (sshTarget) {
+ const remoteBash = createBashTool(process.cwd(), {
+ operations: createSSHOperations(sshTarget),
+ });
+ return remoteBash.execute(id, params, signal, onUpdate);
+ }
+ return localBash.execute(id, params, signal, onUpdate);
+ },
+ });
+}
+```
+
+
+
+Override built-in tools for logging/access control — omit renderCall/renderResult to keep built-in rendering:
+
+```typescript
+import { createReadTool } from "@mariozechner/pi-coding-agent";
+import { Type } from "@sinclair/typebox";
+
+pi.registerTool({
+ name: "read", // Same name = overrides built-in
+ label: "Read (Logged)",
+ description: "Read file contents with logging",
+ parameters: Type.Object({
+ path: Type.String(),
+ offset: Type.Optional(Type.Number()),
+ limit: Type.Optional(Type.Number()),
+ }),
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
+ console.log(`[AUDIT] Reading: ${params.path}`);
+ const builtIn = createReadTool(ctx.cwd);
+ return builtIn.execute(toolCallId, params, signal, onUpdate);
+ },
+ // Omit renderCall/renderResult → built-in renderer used automatically
+});
+```
+
+**Must match exact result shape** including `details` type.
+
diff --git a/src/resources/skills/create-gsd-extension/references/state-management.md b/src/resources/skills/create-gsd-extension/references/state-management.md
new file mode 100644
index 000000000..717293cb0
--- /dev/null
+++ b/src/resources/skills/create-gsd-extension/references/state-management.md
@@ -0,0 +1,70 @@
+
+State management patterns for extensions — tool result details (branch-safe) and appendEntry (private).
+
+
+
+**Recommended for stateful tools.** State in `details` works correctly with branching/forking.
+
+```typescript
+export default function (pi: ExtensionAPI) {
+ let items: string[] = [];
+
+ // Reconstruct state from session on load
+ pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
+ pi.on("session_switch", async (_event, ctx) => reconstructState(ctx));
+ pi.on("session_fork", async (_event, ctx) => reconstructState(ctx));
+ pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
+
+ const reconstructState = (ctx: ExtensionContext) => {
+ items = [];
+ for (const entry of ctx.sessionManager.getBranch()) {
+ if (entry.type === "message" && entry.message.role === "toolResult") {
+ if (entry.message.toolName === "my_tool") {
+ items = entry.message.details?.items ?? [];
+ }
+ }
+ }
+ };
+
+ pi.registerTool({
+ name: "my_tool",
+ // ...
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
+ items.push(params.text);
+ return {
+ content: [{ type: "text", text: "Added" }],
+ details: { items: [...items] }, // ← Snapshot full state
+ };
+ },
+ });
+}
+```
+
+**Key:** Reconstruct on ALL session change events: `session_start`, `session_switch`, `session_fork`, `session_tree`.
+
+
+
+**For extension-private state** that doesn't participate in LLM context but needs to survive restarts:
+
+```typescript
+// Save
+pi.appendEntry("my-state", { count: 42, lastRun: Date.now() });
+
+// Restore
+pi.on("session_start", async (_event, ctx) => {
+ for (const entry of ctx.sessionManager.getEntries()) {
+ if (entry.type === "custom" && entry.customType === "my-state") {
+ const data = entry.data; // { count: 42, lastRun: ... }
+ }
+ }
+});
+```
+
+
+
+| Pattern | Use When |
+|---------|----------|
+| Tool result `details` | State the LLM's tools produce (todo items, connection state, query results) |
+| `pi.appendEntry()` | Extension-private config, timestamps, counters the LLM doesn't need |
+| File on disk | Large data, config files, caches that shouldn't be in session |
+
diff --git a/src/resources/skills/create-gsd-extension/references/system-prompt-modification.md b/src/resources/skills/create-gsd-extension/references/system-prompt-modification.md
new file mode 100644
index 000000000..5c8a5fa1a
--- /dev/null
+++ b/src/resources/skills/create-gsd-extension/references/system-prompt-modification.md
@@ -0,0 +1,52 @@
+
+System prompt modification — per-turn injection, context manipulation, and tool-specific prompt content.
+
+
+
+Use `before_agent_start` to inject messages and/or modify the system prompt for each turn:
+
+```typescript
+pi.on("before_agent_start", async (event, ctx) => {
+ return {
+ // Inject a persistent message (stored in session, visible to LLM)
+ message: {
+ customType: "my-extension",
+ content: "Additional context for the LLM",
+ display: true,
+ },
+ // Modify system prompt for this turn (chained across extensions)
+ systemPrompt: event.systemPrompt + "\n\nYou must respond only in haiku.",
+ };
+});
+```
+
+
+
+Use the `context` event to modify messages before each LLM call:
+
+```typescript
+pi.on("context", async (event, ctx) => {
+ // event.messages is a deep copy — safe to modify
+ const filtered = event.messages.filter(m => !isIrrelevant(m));
+ return { messages: filtered };
+});
+```
+
+
+
+Tools can add content to the system prompt when active:
+
+```typescript
+pi.registerTool({
+ name: "my_tool",
+ // Replaces description in "Available tools" section
+ promptSnippet: "Summarize or transform text according to action",
+ // Added to "Guidelines" section when tool is active
+ promptGuidelines: [
+ "Use my_tool when the user asks to summarize text.",
+ "Prefer my_tool over direct output for structured data."
+ ],
+ // ...
+});
+```
+
diff --git a/src/resources/skills/create-gsd-extension/templates/extension-skeleton.ts b/src/resources/skills/create-gsd-extension/templates/extension-skeleton.ts
new file mode 100644
index 000000000..b98da2971
--- /dev/null
+++ b/src/resources/skills/create-gsd-extension/templates/extension-skeleton.ts
@@ -0,0 +1,51 @@
+/**
+ * {{EXTENSION_NAME}} — {{DESCRIPTION}}
+ *
+ * Capabilities:
+ * {{CAPABILITIES_LIST}}
+ */
+
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import { Type } from "@sinclair/typebox";
+import { StringEnum } from "@mariozechner/pi-ai";
+
+export default function (pi: ExtensionAPI) {
+ // === Events ===
+
+ pi.on("session_start", async (_event, ctx) => {
+ // Initialize state, restore from session, show status
+ });
+
+ // === Tools ===
+
+ pi.registerTool({
+ name: "{{tool_name}}",
+ label: "{{Tool Label}}",
+ description: "{{Tool description for LLM}}",
+ parameters: Type.Object({
+ action: StringEnum(["list", "add"] as const),
+ text: Type.Optional(Type.String({ description: "Item text" })),
+ }),
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
+ if (signal?.aborted) {
+ return { content: [{ type: "text", text: "Cancelled" }] };
+ }
+
+ // Do work here
+
+ return {
+ content: [{ type: "text", text: "Result for LLM" }],
+ details: {},
+ };
+ },
+ });
+
+ // === Commands ===
+
+ pi.registerCommand("{{command_name}}", {
+ description: "{{Command description}}",
+ handler: async (args, ctx) => {
+ ctx.ui.notify(`Running ${args}`, "info");
+ },
+ });
+}
diff --git a/src/resources/skills/create-gsd-extension/templates/stateful-tool-skeleton.ts b/src/resources/skills/create-gsd-extension/templates/stateful-tool-skeleton.ts
new file mode 100644
index 000000000..1ba1483fe
--- /dev/null
+++ b/src/resources/skills/create-gsd-extension/templates/stateful-tool-skeleton.ts
@@ -0,0 +1,143 @@
+/**
+ * {{EXTENSION_NAME}} — Stateful tool with persistence
+ *
+ * State is stored in tool result details for proper branching support.
+ */
+
+import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
+import { Type } from "@sinclair/typebox";
+import { StringEnum } from "@mariozechner/pi-ai";
+import { Text, truncateToWidth, matchesKey, Key } from "@mariozechner/pi-tui";
+
+interface {{ItemType}} {
+ id: number;
+ // Add fields
+}
+
+interface {{ToolDetails}} {
+ action: string;
+ items: {{ItemType}}[];
+ nextId: number;
+ error?: string;
+}
+
+export default function (pi: ExtensionAPI) {
+ let items: {{ItemType}}[] = [];
+ let nextId = 1;
+
+ // Reconstruct state from session
+ const reconstructState = (ctx: ExtensionContext) => {
+ items = [];
+ nextId = 1;
+ for (const entry of ctx.sessionManager.getBranch()) {
+ if (entry.type === "message" && entry.message.role === "toolResult") {
+ if (entry.message.toolName === "{{tool_name}}") {
+ const details = entry.message.details as {{ToolDetails}} | undefined;
+ if (details) {
+ items = details.items;
+ nextId = details.nextId;
+ }
+ }
+ }
+ }
+ };
+
+ // Reconstruct on ALL session change events
+ pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
+ pi.on("session_switch", async (_event, ctx) => reconstructState(ctx));
+ pi.on("session_fork", async (_event, ctx) => reconstructState(ctx));
+ pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
+
+ // Register the tool
+ pi.registerTool({
+ name: "{{tool_name}}",
+ label: "{{Tool Label}}",
+ description: "{{Description for LLM}}",
+ parameters: Type.Object({
+ action: StringEnum(["list", "add", "remove"] as const),
+ text: Type.Optional(Type.String({ description: "Item text" })),
+ id: Type.Optional(Type.Number({ description: "Item ID" })),
+ }),
+
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
+ if (signal?.aborted) {
+ return { content: [{ type: "text", text: "Cancelled" }] };
+ }
+
+ switch (params.action) {
+ case "list":
+ return {
+ content: [{ type: "text", text: items.length ? JSON.stringify(items) : "No items" }],
+ details: { action: "list", items: [...items], nextId } as {{ToolDetails}},
+ };
+
+ case "add": {
+ if (!params.text) throw new Error("text required for add");
+ const item: {{ItemType}} = { id: nextId++ /* , ... */ };
+ items.push(item);
+ return {
+ content: [{ type: "text", text: `Added #${item.id}` }],
+ details: { action: "add", items: [...items], nextId } as {{ToolDetails}},
+ };
+ }
+
+ case "remove": {
+ if (params.id === undefined) throw new Error("id required for remove");
+ const idx = items.findIndex(i => i.id === params.id);
+ if (idx === -1) throw new Error(`Item #${params.id} not found`);
+ items.splice(idx, 1);
+ return {
+ content: [{ type: "text", text: `Removed #${params.id}` }],
+ details: { action: "remove", items: [...items], nextId } as {{ToolDetails}},
+ };
+ }
+
+ default:
+ throw new Error(`Unknown action: ${params.action}`);
+ }
+ },
+
+ // Custom rendering
+ renderCall(args, theme) {
+ let text = theme.fg("toolTitle", theme.bold("{{tool_name}} "));
+ text += theme.fg("muted", args.action);
+ return new Text(text, 0, 0);
+ },
+
+ renderResult(result, { expanded }, theme) {
+ const details = result.details as {{ToolDetails}} | undefined;
+ if (!details) return new Text("", 0, 0);
+ if (details.error) return new Text(theme.fg("error", details.error), 0, 0);
+ return new Text(theme.fg("success", `✓ ${details.action} (${details.items.length} items)`), 0, 0);
+ },
+ });
+
+ // User command to view state
+ pi.registerCommand("{{command_name}}", {
+ description: "View {{items}}",
+ handler: async (_args, ctx) => {
+ if (!ctx.hasUI) {
+ ctx.ui.notify("Requires interactive mode", "error");
+ return;
+ }
+ await ctx.ui.custom((_tui, theme, _kb, done) => ({
+ render(width: number): string[] {
+ const lines = [
+ "",
+ truncateToWidth(theme.fg("accent", ` {{Items}} (${items.length}) `), width),
+ "",
+ ];
+ for (const item of items) {
+ lines.push(truncateToWidth(` #${item.id}`, width));
+ }
+ lines.push("", truncateToWidth(theme.fg("dim", " Press Escape to close"), width), "");
+ return lines;
+ },
+ handleInput(data: string) {
+ if (matchesKey(data, Key.escape)) done();
+ },
+ invalidate() {},
+ }));
+ },
+ });
+}
diff --git a/src/resources/skills/create-gsd-extension/workflows/add-capability.md b/src/resources/skills/create-gsd-extension/workflows/add-capability.md
new file mode 100644
index 000000000..a069e4570
--- /dev/null
+++ b/src/resources/skills/create-gsd-extension/workflows/add-capability.md
@@ -0,0 +1,57 @@
+
+Read the reference file for the specific capability being added:
+- Tools → references/custom-tools.md
+- Commands → references/custom-commands.md
+- Events → references/events-reference.md
+- UI → references/custom-ui.md
+- Rendering → references/custom-rendering.md
+- State → references/state-management.md
+- System prompt → references/system-prompt-modification.md
+
+
+
+
+## Step 1: Identify the Extension
+
+Locate the existing extension file. Check:
+- `~/.gsd/agent/extensions/` (global)
+- `.gsd/extensions/` (project-local)
+
+Read the current extension code to understand its structure.
+
+## Step 2: Add the Capability
+
+Add the new registration/hook inside the existing `export default function (pi: ExtensionAPI)` body. Follow the patterns in the relevant reference file.
+
+If the extension needs new imports, add them at the top of the file.
+
+## Step 3: Handle Structural Changes
+
+**Single file → Directory**: If the extension is outgrowing a single file:
+1. Create `~/.gsd/agent/extensions/my-extension/`
+2. Move the file to `index.ts`
+3. Extract helpers to separate files
+
+**Adding npm dependencies**: If new packages are needed:
+1. Create `package.json` in the extension directory
+2. Add dependencies
+3. Run `npm install`
+4. Add `"pi": { "extensions": ["./index.ts"] }` to package.json
+
+## Step 4: Test
+
+```bash
+/reload
+```
+
+Verify the new capability works alongside existing ones.
+
+
+
+
+Capability addition is complete when:
+- [ ] New capability added without breaking existing functionality
+- [ ] All new imports resolve
+- [ ] `/reload` succeeds
+- [ ] New tool/command/hook tested with real invocation
+
diff --git a/src/resources/skills/create-gsd-extension/workflows/create-extension.md b/src/resources/skills/create-gsd-extension/workflows/create-extension.md
new file mode 100644
index 000000000..817efa13b
--- /dev/null
+++ b/src/resources/skills/create-gsd-extension/workflows/create-extension.md
@@ -0,0 +1,156 @@
+
+**Read these reference files before proceeding:**
+1. references/extension-lifecycle.md
+2. references/custom-tools.md (if building tools)
+3. references/custom-commands.md (if building commands)
+4. references/events-reference.md (if building event hooks)
+5. references/key-rules-gotchas.md (always)
+
+
+
+
+## Step 1: Determine Scope and Placement
+
+Ask the user:
+- **Global** (`~/.gsd/agent/extensions/`) — Available in all GSD sessions
+- **Project-local** (`.gsd/extensions/`) — Available only in this project
+
+## Step 2: Determine Extension Capabilities
+
+Identify what the extension needs from the user's description:
+
+| Capability | API | When |
+|------------|-----|------|
+| Custom tool (LLM-callable) | `pi.registerTool()` | LLM needs to perform new actions |
+| Slash command | `pi.registerCommand()` | User needs direct actions |
+| Event interception | `pi.on("event", ...)` | Block/modify tool calls, inject context, react to lifecycle |
+| Custom UI | `ctx.ui.custom()` | Complex interactive displays |
+| System prompt modification | `before_agent_start` event | Add per-turn instructions |
+| Context filtering | `context` event | Modify messages sent to LLM |
+| State persistence | `details` in tool results or `pi.appendEntry()` | Stateful behavior |
+| Custom rendering | `renderCall` / `renderResult` | Control how tools appear in TUI |
+| Provider management | `pi.registerProvider()` | Custom model endpoints |
+| Keyboard shortcut | `pi.registerShortcut()` | Hotkey triggers |
+
+## Step 3: Choose Extension Structure
+
+**Single file** — for small extensions (1-2 tools/commands, simple hooks):
+```
+~/.gsd/agent/extensions/my-extension.ts
+```
+
+**Directory with index.ts** — for multi-file extensions:
+```
+~/.gsd/agent/extensions/my-extension/
+├── index.ts
+├── tools.ts
+└── utils.ts
+```
+
+**Package with dependencies** — when npm packages are needed:
+```
+~/.gsd/agent/extensions/my-extension/
+├── package.json
+├── src/index.ts
+└── node_modules/
+```
+
+For packages, `package.json` needs:
+```json
+{
+ "name": "my-extension",
+ "dependencies": { ... },
+ "pi": { "extensions": ["./src/index.ts"] }
+}
+```
+
+## Step 4: Write the Extension
+
+Start with the skeleton:
+
+```typescript
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+
+export default function (pi: ExtensionAPI) {
+ // Register events, tools, commands here
+}
+```
+
+Then add capabilities based on Step 2. Reference the appropriate reference files for each capability.
+
+**Tool registration pattern:**
+```typescript
+import { Type } from "@sinclair/typebox";
+import { StringEnum } from "@mariozechner/pi-ai";
+
+pi.registerTool({
+ name: "my_tool",
+ label: "My Tool",
+ description: "What this tool does (shown to LLM)",
+ parameters: Type.Object({
+ action: StringEnum(["list", "add"] as const),
+ text: Type.Optional(Type.String({ description: "Item text" })),
+ }),
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
+ if (signal?.aborted) return { content: [{ type: "text", text: "Cancelled" }] };
+ return {
+ content: [{ type: "text", text: "Result for LLM" }],
+ details: { data: "for rendering and state" },
+ };
+ },
+});
+```
+
+**Command registration pattern:**
+```typescript
+pi.registerCommand("mycommand", {
+ description: "What this command does",
+ handler: async (args, ctx) => {
+ ctx.ui.notify(`Running with args: ${args}`, "info");
+ },
+});
+```
+
+**Event hook pattern:**
+```typescript
+pi.on("tool_call", async (event, ctx) => {
+ if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
+ return { block: true, reason: "Blocked dangerous command" };
+ }
+});
+```
+
+## Step 5: Test the Extension
+
+```bash
+# Quick test without installing
+gsd -e ./path/to/my-extension.ts
+
+# Or place in extensions dir and reload
+/reload
+```
+
+Verify:
+- Extension loads without errors (check GSD startup output)
+- Tools appear when LLM is asked to use them
+- Commands respond to `/mycommand`
+- Event hooks trigger at expected points
+
+## Step 6: Iterate
+
+Fix issues, add features, refine. Use `/reload` for hot-reload during development.
+
+
+
+
+Extension creation is complete when:
+- [ ] Extension file(s) written to correct location
+- [ ] All imports resolve (TypeBox, pi-ai, pi-coding-agent, pi-tui as needed)
+- [ ] Tools use `StringEnum` for string enums (not `Type.Union`/`Type.Literal`)
+- [ ] Tool output is truncated if variable-length
+- [ ] State stored in `details` if extension is stateful
+- [ ] `ctx.hasUI` checked before dialog methods
+- [ ] Extension loads on `/reload` without errors
+- [ ] Tools callable by LLM, commands by user
+- [ ] Tested with at least one real invocation
+
diff --git a/src/resources/skills/create-gsd-extension/workflows/debug-extension.md b/src/resources/skills/create-gsd-extension/workflows/debug-extension.md
new file mode 100644
index 000000000..ceef023ee
--- /dev/null
+++ b/src/resources/skills/create-gsd-extension/workflows/debug-extension.md
@@ -0,0 +1,74 @@
+
+1. references/key-rules-gotchas.md
+2. references/extension-lifecycle.md
+
+
+
+
+## Step 1: Identify the Symptom
+
+| Symptom | Likely Cause |
+|---------|--------------|
+| Extension not loading | File not in discovery path, syntax error, missing export default |
+| Tool not appearing for LLM | Tool not registered, `pi.setActiveTools()` excluding it, tool name conflict |
+| Command not responding | Command not registered, name collision with built-in |
+| Event not firing | Wrong event name, handler returning too early, handler error (logged but swallowed) |
+| UI not rendering | `ctx.hasUI` is false (print mode), render lines exceed width, component not returning lines |
+| State lost on restart | State not stored in `details` or `appendEntry`, not reconstructing on `session_start` |
+| Google API errors | Using `Type.Union`/`Type.Literal` instead of `StringEnum` |
+| Context overflow | Tool output not truncated |
+| Deadlock/hang | Session control methods called from event handler (must be in command handler only) |
+| Render garbage | Theme imported directly instead of from callback, missing `truncateToWidth()` |
+
+## Step 2: Check Extension Loading
+
+```bash
+# Test in isolation
+gsd -e ./path/to/extension.ts
+
+# Check GSD startup output for errors
+# Extension errors are logged but don't crash GSD
+```
+
+## Step 3: Verify File Location
+
+Extensions must be in auto-discovery paths:
+- `~/.gsd/agent/extensions/*.ts`
+- `~/.gsd/agent/extensions/*/index.ts`
+- `.gsd/extensions/*.ts`
+- `.gsd/extensions/*/index.ts`
+
+The file must `export default function(pi: ExtensionAPI) { ... }`.
+
+## Step 4: Check for Common Mistakes
+
+Read `references/key-rules-gotchas.md` and verify each rule against the extension code.
+
+## Step 5: Add Debugging
+
+```typescript
+// Temporary: log to stderr (visible in GSD output)
+console.error("[my-ext] Loading...");
+
+pi.on("session_start", async (_event, ctx) => {
+ console.error("[my-ext] Session started");
+ ctx.ui.notify("Extension loaded", "info");
+});
+```
+
+## Step 6: Fix and Reload
+
+Apply the fix and test:
+```
+/reload
+```
+
+
+
+
+Debugging is complete when:
+- [ ] Root cause identified
+- [ ] Fix applied
+- [ ] Extension loads and functions correctly after `/reload`
+- [ ] No regression in existing functionality
+