> Shortcut can create extensions. Ask it to build one for your use case.

# Extensions

Extensions are TypeScript modules that extend Shortcut's behavior. They can subscribe to lifecycle events, register custom tools callable by the LLM, add commands, and more.

> **Placement for /reload:** Put extensions in `~/.shortcut/agent/extensions/` (global) or `.shortcut/extensions/` (project-local) for auto-discovery. Use `shortcut -e ./path.ts` only for quick tests. Extensions in auto-discovered locations can be hot-reloaded with `/reload`.

**Key capabilities:**

- **Custom tools** - Register tools the LLM can call via `shortcut.registerTool()`
- **Event interception** - Block or modify tool calls, customize compaction
- **User interaction** - Prompt users via `ctx.ui` (select, confirm, input, notify)
- **Custom UI components** - Full TUI components with keyboard input via `ctx.ui.custom()` for complex interactions
- **Custom commands** - Register commands like `/mycommand` via `shortcut.registerCommand()`
- **Session persistence** - Store state that survives restarts via `shortcut.appendEntry()`
- **Custom rendering** - Control how tool calls/results appear in TUI via `renderCall`/`renderResult`

**Example use cases:**

- Permission gates (confirm before `rm -rf`, `sudo`, etc.)
- Git checkpointing (stash at each turn, restore on branch)
- Path protection (block writes to `.env`, `node_modules/`)
- Custom compaction (summarize conversation your way)
- Conversation summaries (see `summarize.ts` example)
- Interactive tools (questions, wizards, custom dialogs)
- Stateful tools (todo lists, connection pools)
- External integrations (file watchers, webhooks, CI triggers)
- Games while you wait (see `snake.ts` example)

See [examples/extensions/](../examples/extensions/) for working implementations.

## Table of Contents

- [Quick Start](#quick-start)
- [Extension Locations](#extension-locations)
- [Available Imports](#available-imports)
- [Writing an Extension](#writing-an-extension)
  - [Extension Styles](#extension-styles)
- [Events](#events)
  - [Lifecycle Overview](#lifecycle-overview)
  - [Session Events](#session-events)
  - [AgentController Events](#agent-events)
  - [Tool Events](#tool-events)
- [ExtensionContext](#extensioncontext)
- [ExtensionCommandContext](#extensioncommandcontext)
- [ExtensionAPI Methods](#extensionapi-methods)
- [State Management](#state-management)
- [Custom Tools](#custom-tools)
- [Custom UI](#custom-ui)
- [Error Handling](#error-handling)
- [Mode Behavior](#mode-behavior)
- [Examples Reference](#examples-reference)

## Quick Start

Create `~/.shortcut/agent/extensions/my-extension.ts`:

```typescript
import type { ExtensionAPI } from 'shortcutxl/app';
import { Type } from '@sinclair/typebox';

export default function (shortcut: ExtensionAPI) {
  // React to events
  shortcut.on('session_start', async (_event, ctx) => {
    ctx.ui.notify('Extension loaded!', 'info');
  });

  shortcut.on('tool_call', async (event, ctx) => {
    if (event.toolName === 'bash' && event.input.command?.includes('rm -rf')) {
      const ok = await ctx.ui.confirm('Dangerous!', 'Allow rm -rf?');
      if (!ok) return { block: true, reason: 'Blocked by user' };
    }
  });

  // Register a custom tool
  shortcut.registerTool({
    name: 'greet',
    label: 'Greet',
    description: 'Greet someone by name',
    parameters: Type.Object({
      name: Type.String({ description: 'Name to greet' })
    }),
    async execute(toolCallId, params, signal, onUpdate, ctx) {
      return {
        content: [{ type: 'text', text: `Hello, ${params.name}!` }],
        details: {}
      };
    }
  });

  // Register a command
  shortcut.registerCommand('hello', {
    description: 'Say hello',
    handler: async (args, ctx) => {
      ctx.ui.notify(`Hello ${args || 'world'}!`, 'info');
    }
  });
}
```

Test with `--extension` (or `-e`) flag:

```bash
shortcut -e ./my-extension.ts
```

## Extension Locations

> **Security:** Extensions run with your full system permissions and can execute arbitrary code. Only install from sources you trust.

Extensions are auto-discovered from:

| Location                                  | Scope                        |
| ----------------------------------------- | ---------------------------- |
| `~/.shortcut/agent/extensions/*.ts`       | Global (all projects)        |
| `~/.shortcut/agent/extensions/*/index.ts` | Global (subdirectory)        |
| `.shortcut/extensions/*.ts`               | Project-local                |
| `.shortcut/extensions/*/index.ts`         | Project-local (subdirectory) |

Additional paths via `settings.json`:

```json
{
  "packages": ["npm:@foo/bar@1.0.0", "git:github.com/user/repo@v1"],
  "extensions": ["/path/to/local/extension.ts", "/path/to/local/extension/dir"]
}
```

To share extensions via npm or git as Shortcut packages, see [packages.md](packages.md).

## Available Imports

| Package               | Purpose                                                        |
| --------------------- | -------------------------------------------------------------- |
| `shortcutxl/app`      | Extension types and headless ShortcutXL runtime APIs           |
| `@sinclair/typebox`   | Schema definitions for tool parameters                         |
| `shortcutxl/ai` | AI utilities (`StringEnum`, model helpers, completion helpers) |
| `shortcutxl/tui`      | TUI components for custom rendering                            |
| `shortcutxl/shell`    | Shell-owned render helpers and components                      |

npm dependencies work too. Add a `package.json` next to your extension (or in a parent directory), run `pnpm install`, and imports from `node_modules/` are resolved automatically.

Node.js built-ins (`node:fs`, `node:path`, etc.) are also available.

## Writing an Extension

An extension exports a default function that receives `ExtensionAPI`:

```typescript
import type { ExtensionAPI } from "shortcutxl/app";

export default function (shortcut: ExtensionAPI) {
  // Subscribe to events
  shortcut.on("event_name", async (event, ctx) => {
    // ctx.ui for user interaction
    const ok = await ctx.ui.confirm("Title", "Are you sure?");
    ctx.ui.notify("Done!", "info");
  });

  // Register tools and commands
  shortcut.registerTool({ ... });
  shortcut.registerCommand("name", { ... });
}
```

Extensions are loaded via [jiti](https://github.com/unjs/jiti), so TypeScript works without compilation.

### Extension Styles

**Single file** - simplest, for small extensions:

```
~/.shortcut/agent/extensions/
└── my-extension.ts
```

**Directory with index.ts** - for multi-file extensions:

```
~/.shortcut/agent/extensions/
└── my-extension/
    ├── index.ts        # Entry point (exports default function)
    ├── tools.ts        # Helper module
    └── utils.ts        # Helper module
```

**Package with dependencies** - for extensions that need npm packages:

```
~/.shortcut/agent/extensions/
└── my-extension/
    ├── package.json    # Declares dependencies and entry points
    ├── pnpm-lock.yaml
    ├── node_modules/   # After pnpm install
    └── src/
        └── index.ts
```

```json
// package.json
{
  "name": "my-extension",
  "dependencies": {
    "zod": "^3.0.0",
    "chalk": "^5.0.0"
  },
  "shortcut": {
    "extensions": ["./src/index.ts"]
  }
}
```

Run `pnpm install` in the extension directory, then imports from `node_modules/` work automatically.

## Events

### Lifecycle Overview

```
Shortcut starts
  │
  └─► session_start
      │
      ▼
user sends prompt ─────────────────────────────────────────┐
  │                                                        │
  ├─► (extension commands checked first, bypass if found)  │
  ├─► input (can intercept, transform, or handle)          │
  ├─► (skill/template expansion if not handled)            │
  ├─► before_agent_start (can inject message, modify system prompt)
  ├─► agent_start                                          │
  ├─► message_start / message_update / message_end         │
  │                                                        │
  │   ┌─── turn (repeats while LLM calls tools) ───┐       │
  │   │                                            │       │
  │   ├─► turn_start                               │       │
  │   ├─► context (can modify messages)            │       │
  │   │                                            │       │
  │   │   LLM responds, may call tools:            │       │
  │   │     ├─► tool_call (can block)              │       │
  │   │     ├─► tool_execution_start               │       │
  │   │     ├─► tool_execution_update              │       │
  │   │     ├─► tool_execution_end                 │       │
  │   │     └─► tool_result (can modify)           │       │
  │   │                                            │       │
  │   └─► turn_end                                 │       │
  │                                                        │
  └─► agent_end                                            │
                                                           │
user sends another prompt ◄────────────────────────────────┘

/new (new session) or /resume (switch session)
  ├─► session_before_switch (can cancel)
  └─► session_switch

/compact or auto-compaction
  ├─► session_before_compact (can cancel or customize)
  └─► session_compact

exit (Ctrl+C, Ctrl+D)
  └─► session_shutdown
```

### Session Events

See [session.md](session.md) for session storage internals and the SessionManager API.

#### session_start

Fired on initial session load.

```typescript
shortcut.on('session_start', async (_event, ctx) => {
  ctx.ui.notify(`Session: ${ctx.sessionManager.getSessionFile() ?? 'ephemeral'}`, 'info');
});
```

#### session_before_switch / session_switch

Fired when starting a new session (`/new`) or switching sessions (`/resume`).

```typescript
shortcut.on('session_before_switch', async (event, ctx) => {
  // event.reason - "new" or "resume"
  // event.targetSessionFile - session we're switching to (only for "resume")

  if (event.reason === 'new') {
    const ok = await ctx.ui.confirm('Clear?', 'Delete all messages?');
    if (!ok) return { cancel: true };
  }
});

shortcut.on('session_switch', async (event, ctx) => {
  // event.reason - "new" or "resume"
  // event.previousSessionFile - session we came from
});
```

#### session_before_compact / session_compact

Fired on compaction. See [compaction.md](compaction.md) for details.

```typescript
shortcut.on('session_before_compact', async (event, ctx) => {
  const { preparation, branchEntries, customInstructions, signal } = event;

  // Cancel:
  return { cancel: true };

  // Custom summary:
  return {
    compaction: {
      summary: '...',
      firstKeptEntryId: preparation.firstKeptEntryId,
      tokensBefore: preparation.tokensBefore
    }
  };
});

shortcut.on('session_compact', async (event, ctx) => {
  // event.compactionEntry - the saved compaction
  // event.fromExtension - whether extension provided it
});
```

#### session_shutdown

Fired on exit (Ctrl+C, Ctrl+D, SIGTERM).

```typescript
shortcut.on('session_shutdown', async (_event, ctx) => {
  // Cleanup, save state, etc.
});
```

### AgentController Events

#### before_agent_start

Fired after user submits prompt, before agent loop. Can inject a message and/or modify the system prompt.

```typescript
shortcut.on('before_agent_start', async (event, ctx) => {
  // event.prompt - user's prompt text
  // event.images - attached images (if any)
  // event.systemPrompt - current system prompt

  return {
    // Inject a persistent message (stored in session, sent to LLM)
    message: {
      customType: 'my-extension',
      content: 'Additional context for the LLM',
      display: true
    },
    // Replace the system prompt for this turn (chained across extensions)
    systemPrompt: event.systemPrompt + '\n\nExtra instructions for this turn...'
  };
});
```

#### agent_start / agent_end

Fired once per user prompt.

```typescript
shortcut.on('agent_start', async (_event, ctx) => {});

shortcut.on('agent_end', async (event, ctx) => {
  // event.messages - messages from this prompt
});
```

#### turn_start / turn_end

Fired for each turn (one LLM response + tool calls).

```typescript
shortcut.on('turn_start', async (event, ctx) => {
  // event.turnIndex, event.timestamp
});

shortcut.on('turn_end', async (event, ctx) => {
  // event.turnIndex, event.message, event.toolResults
});
```

#### message_start / message_update / message_end

Fired for message lifecycle updates.

- `message_start` and `message_end` fire for user, assistant, and toolResult messages.
- `message_update` fires for assistant streaming updates.

```typescript
shortcut.on('message_start', async (event, ctx) => {
  // event.message
});

shortcut.on('message_update', async (event, ctx) => {
  // event.message
  // event.assistantMessageEvent (token-by-token stream event)
});

shortcut.on('message_end', async (event, ctx) => {
  // event.message
});
```

#### tool_execution_start / tool_execution_update / tool_execution_end

Fired for tool execution lifecycle updates.

```typescript
shortcut.on('tool_execution_start', async (event, ctx) => {
  // event.toolCallId, event.toolName, event.args
});

shortcut.on('tool_execution_update', async (event, ctx) => {
  // event.toolCallId, event.toolName, event.args, event.partialResult
});

shortcut.on('tool_execution_end', async (event, ctx) => {
  // event.toolCallId, event.toolName, event.result, event.isError
});
```

#### context

Fired before each LLM call. Modify messages non-destructively. See [session.md](session.md) for message types.

```typescript
shortcut.on('context', async (event, ctx) => {
  // event.messages - deep copy, safe to modify
  const filtered = event.messages.filter((m) => !shouldPrune(m));
  return { messages: filtered };
});
```

### Tool Events

#### tool_call

Fired before tool executes. **Can block.** Narrow by `event.toolName` and cast `event.input` to the expected tool input type.

```typescript
shortcut.on('tool_call', async (event, ctx) => {
  // event.toolName - "bash", "read", "write", "edit", etc.
  // event.toolCallId
  // event.input - tool parameters

  if (event.toolName === 'bash') {
    const input = event.input as { command: string; timeout?: number };
    if (input.command.includes('rm -rf')) {
      return { block: true, reason: 'Dangerous command' };
    }
  }

  if (event.toolName === 'read') {
    const input = event.input as { path: string; offset?: number; limit?: number };
    console.log(`Reading: ${input.path}`);
  }
});
```

#### Typing custom tool input

Custom tools should export their input type:

```typescript
// my-extension.ts
export type MyToolInput = Static<typeof myToolSchema>;
```

Use `event.toolName` plus your own input type:

```typescript
import type { MyToolInput } from 'my-extension';

shortcut.on('tool_call', (event) => {
  if (event.toolName === 'my_tool') {
    const input = event.input as MyToolInput;
    input.action; // typed
  }
});
```

#### tool_result

Fired after tool executes. **Can modify result.**

`tool_result` handlers chain like middleware:

- Handlers run in extension load order
- Each handler sees the latest result after previous handler changes
- Handlers can return partial patches (`content`, `details`, or `isError`); omitted fields keep their current values

```typescript
shortcut.on("tool_result", async (event, ctx) => {
  // event.toolName, event.toolCallId, event.input
  // event.content, event.details, event.isError

  if (event.toolName === "bash") {
    // event.details is typed as BashToolDetails
  }

  // Modify result:
  return { content: [...], details: {...}, isError: false };
});
```

### User Bash Events

#### user_bash

Fired when user executes `!` or `!!` commands. **Can intercept.**

```typescript
shortcut.on('user_bash', (event, ctx) => {
  // event.command - the bash command
  // event.excludeFromContext - true if !! prefix
  // event.cwd - working directory

  // Option 1: Provide custom operations (e.g., SSH)
  return { operations: remoteBashOps };

  // Option 2: Full replacement - return result directly
  return { result: { output: '...', exitCode: 0, cancelled: false, truncated: false } };
});
```

### Input Events

#### input

Fired when user input is received, after extension commands are checked but before skill and template expansion. The event sees the raw input text, so `/skill:foo` and `/template` are not yet expanded.

**Processing order:**

1. Extension commands (`/cmd`) checked first - if found, handler runs and input event is skipped
2. `input` event fires - can intercept, transform, or handle
3. If not handled: skill commands (`/skill:name`) rewritten to selected-skill markers
4. If not handled: prompt templates (`/template`) expanded to template content
5. AgentController processing begins (`before_agent_start`, etc.)

```typescript
shortcut.on('input', async (event, ctx) => {
  // event.text - raw input (before skill/template expansion)
  // event.images - attached images, if any
  // event.source - "interactive" (typed), "rpc" (API), or "extension" (via sendUserMessage)

  // Transform: rewrite input before expansion
  if (event.text.startsWith('?quick '))
    return { action: 'transform', text: `Respond briefly: ${event.text.slice(7)}` };

  // Handle: respond without LLM (extension shows its own feedback)
  if (event.text === 'ping') {
    ctx.ui.notify('pong', 'info');
    return { action: 'handled' };
  }

  // Route by source: skip processing for extension-injected messages
  if (event.source === 'extension') return { action: 'continue' };

  // Intercept skill commands before expansion
  if (event.text.startsWith('/skill:')) {
    // Could transform, block, or let pass through
  }

  return { action: 'continue' }; // Default: pass through to expansion
});
```

**Results:**

- `continue` - pass through unchanged (default if handler returns nothing)
- `transform` - modify text/images, then continue to expansion
- `handled` - skip agent entirely (first handler to return this wins)

Transforms chain across handlers. See [input-transform.ts](../examples/extensions/input-transform.ts).

## ExtensionContext

Every handler receives `ctx: ExtensionContext`:

### ctx.ui

UI methods for user interaction. See [Custom UI](#custom-ui) for full details.

### ctx.hasUI

`false` in print mode (`-p`) and JSON mode. `true` in interactive and RPC mode. In RPC mode, dialog methods (`select`, `confirm`, `input`) work via the extension UI sub-protocol, and `notify` emits requests to the client. Check `ctx.hasUI` before using UI methods in extensions that need to work across modes.

### ctx.cwd

Current working directory.

### ctx.sessionManager

Read-only access to session state. See [session.md](session.md) for the full SessionManager API and entry types.

```typescript
ctx.sessionManager.getEntries(); // All entries
ctx.sessionManager.getBranch(); // Current branch
ctx.sessionManager.getLeafId(); // Current leaf entry ID
```

### ctx.modelRegistry / ctx.model

Access to models and API keys.

### ctx.isIdle() / ctx.abort() / ctx.hasPendingMessages()

Control flow helpers.

### ctx.shutdown()

Request a graceful shutdown of Shortcut.

- **Interactive mode:** Deferred until the agent becomes idle (after processing all queued steering and follow-up messages).
- **RPC mode:** Deferred until the next idle state (after completing the current command response, when waiting for the next command).
- **Print mode:** No-op. The process exits automatically when all prompts are processed.

Emits `session_shutdown` event to all extensions before exiting. Available in all contexts (event handlers, tools, commands, shortcuts).

```typescript
shortcut.on('tool_call', (event, ctx) => {
  if (isFatal(event.input)) {
    ctx.shutdown();
  }
});
```

### ctx.getContextUsage()

Returns current context usage for the active model. Uses last assistant usage when available, then estimates tokens for trailing messages.

```typescript
const usage = ctx.getContextUsage();
if (usage && usage.tokens > 100_000) {
  // ...
}
```

### ctx.compact()

Trigger compaction without awaiting completion. Use `onComplete` and `onError` for follow-up actions.

```typescript
ctx.compact({
  customInstructions: 'Focus on recent changes',
  onComplete: (result) => {
    ctx.ui.notify('Compaction completed', 'info');
  },
  onError: (error) => {
    ctx.ui.notify(`Compaction failed: ${error.message}`, 'error');
  }
});
```

### ctx.getSystemPrompt()

Returns the current effective system prompt. This includes any modifications made by `before_agent_start` handlers for the current turn.

```typescript
shortcut.on('before_agent_start', (event, ctx) => {
  const prompt = ctx.getSystemPrompt();
  console.log(`System prompt length: ${prompt.length}`);
});
```

## ExtensionCommandContext

Command handlers receive `ExtensionCommandContext`, which extends `ExtensionContext` with session control methods. These are only available in commands because they can deadlock if called from event handlers.

### ctx.waitForIdle()

Wait for the agent to finish streaming:

```typescript
shortcut.registerCommand('my-cmd', {
  handler: async (args, ctx) => {
    await ctx.waitForIdle();
    // AgentController is now idle, safe to modify session
  }
});
```

### ctx.newSession(options?)

Create a new session:

```typescript
const result = await ctx.newSession({
  parentSession: ctx.sessionManager.getSessionFile(),
  setup: async (sm) => {
    sm.appendMessage({
      role: 'user',
      content: [{ type: 'text', text: 'Context from previous session...' }],
      timestamp: Date.now()
    });
  }
});

if (result.cancelled) {
  // An extension cancelled the new session
}
```

### ctx.fork(entryId)

Fork from a specific entry, creating a new session file:

```typescript
const result = await ctx.fork('entry-id-123');
if (!result.cancelled) {
  // Now in the forked session
}
```

### ctx.navigateTree(targetId, options?)

Navigate to a different point in the session tree:

```typescript
const result = await ctx.navigateTree('entry-id-456', {
  summarize: true,
  customInstructions: 'Focus on error handling changes',
  replaceInstructions: false, // true = replace default prompt entirely
  label: 'review-checkpoint'
});
```

Options:

- `summarize`: Whether to generate a summary of the abandoned branch
- `customInstructions`: Custom instructions for the summarizer
- `replaceInstructions`: If true, `customInstructions` replaces the default prompt instead of being appended
- `label`: Label to attach to the branch summary entry (or target entry if not summarizing)

### ctx.reload()

Run the same reload flow as `/reload`.

```typescript
shortcut.registerCommand('reload-runtime', {
  description: 'Reload extensions, skills, prompts, and themes',
  handler: async (_args, ctx) => {
    await ctx.reload();
    return;
  }
});
```

Important behavior:

- `await ctx.reload()` emits `session_shutdown` for the current extension runtime
- It then reloads resources and emits `session_start` (and `resources_discover` with reason `"reload"`) for the new runtime
- The currently running command handler still continues in the old call frame
- Code after `await ctx.reload()` still runs from the pre-reload version
- Code after `await ctx.reload()` must not assume old in-memory extension state is still valid
- After the handler returns, future commands/events/tool calls use the new extension version

For predictable behavior, treat reload as terminal for that handler (`await ctx.reload(); return;`).

Tools run with `ExtensionContext`, so they cannot call `ctx.reload()` directly. Use a command as the reload entrypoint.

## ExtensionAPI Methods

### shortcut.on(event, handler)

Subscribe to events. See [Events](#events) for event types and return values.

### shortcut.registerTool(definition)

Register a custom tool callable by the LLM. See [Custom Tools](#custom-tools) for full details.

`shortcut.registerTool()` works both during extension load and after startup. You can call it inside `session_start`, command handlers, or other event handlers. New tools are refreshed immediately and are callable by the LLM without `/reload`.

Use `promptSnippet` to customize that tool's one-line entry in `Available tools`, and `promptGuidelines` to append tool-specific bullets to the default `Guidelines` section when the tool is active.

See [dynamic-tools.ts](../examples/extensions/dynamic-tools.ts) for a full example.

```typescript
import { Type } from "@sinclair/typebox";
import { StringEnum } from "shortcutxl/ai";

shortcut.registerTool({
  name: "my_tool",
  label: "My Tool",
  description: "What this tool does",
  promptSnippet: "Summarize or transform text according to action",
  promptGuidelines: ["Use this tool when the user asks to summarize previously generated text."],
  parameters: Type.Object({
    action: StringEnum(["list", "add"] as const),
    text: Type.Optional(Type.String()),
  }),

  async execute(toolCallId, params, signal, onUpdate, ctx) {
    // Stream progress
    onUpdate?.({ content: [{ type: "text", text: "Working..." }] });

    return {
      content: [{ type: "text", text: "Done" }],
      details: { result: "..." },
    };
  },

  // Optional: Custom rendering
  renderCall(args, theme) { ... },
  renderResult(result, options, theme) { ... },
});
```

### shortcut.appendEntry(customType, data?)

Persist extension state (does NOT participate in LLM context).

```typescript
shortcut.appendEntry('my-state', { count: 42 });

// Restore on reload
shortcut.on('session_start', async (_event, ctx) => {
  for (const entry of ctx.sessionManager.getEntries()) {
    if (entry.type === 'custom' && entry.customType === 'my-state') {
      // Reconstruct from entry.data
    }
  }
});
```

### shortcut.registerCommand(name, options)

Register a command.

```typescript
shortcut.registerCommand('stats', {
  description: 'Show session statistics',
  handler: async (args, ctx) => {
    const count = ctx.sessionManager.getEntries().length;
    ctx.ui.notify(`${count} entries`, 'info');
  }
});
```

Optional: add argument auto-completion for `/command ...`:

```typescript
import type { AutocompleteItem } from 'shortcutxl/tui';

shortcut.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: ${args}`, 'info');
  }
});
```

## State Management

Extensions with state should store it in tool result `details` for proper branching support:

```typescript
export default function (shortcut: ExtensionAPI) {
  let items: string[] = [];

  // Reconstruct state from session
  shortcut.on('session_start', async (_event, ctx) => {
    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 ?? [];
        }
      }
    }
  });

  shortcut.registerTool({
    name: 'my_tool',
    // ...
    async execute(toolCallId, params, signal, onUpdate, ctx) {
      items.push('new item');
      return {
        content: [{ type: 'text', text: 'Added' }],
        details: { items: [...items] } // Store for reconstruction
      };
    }
  });
}
```

## Custom Tools

Register tools the LLM can call via `shortcut.registerTool()`. Tools appear in the system prompt and can have custom rendering.

Use `promptSnippet` for a short one-line entry in the `Available tools` section in the default system prompt. If omitted, Shortcut falls back to `description`.

Use `promptGuidelines` to add tool-specific bullets to the default system prompt `Guidelines` section. These bullets are included only while the tool is active (for example, after `shortcut.setActiveTools([...])`).

Note: Some models are idiots and include the @ prefix in tool path arguments. Built-in tools strip a leading @ before resolving paths. If your custom tool accepts a path, normalize a leading @ as well.

### Tool Definition

```typescript
import { Type } from "@sinclair/typebox";
import { StringEnum } from "shortcutxl/ai";
import { Text } from "shortcutxl/tui";

shortcut.registerTool({
  name: "my_tool",
  label: "My Tool",
  description: "What this tool does (shown to LLM)",
  promptSnippet: "List or add items in the project todo list",
  promptGuidelines: [
    "Use this tool for todo planning instead of direct file edits when the user asks for a task list."
  ],
  parameters: Type.Object({
    action: StringEnum(["list", "add"] as const),  // Use StringEnum for Google compatibility
    text: Type.Optional(Type.String()),
  }),

  async execute(toolCallId, params, signal, onUpdate, ctx) {
    // Check for cancellation
    if (signal?.aborted) {
      return { content: [{ type: "text", text: "Cancelled" }] };
    }

    // Stream progress updates
    onUpdate?.({
      content: [{ type: "text", text: "Working..." }],
      details: { progress: 50 },
    });

    // Return result
    return {
      content: [{ type: "text", text: "Done" }],  // Sent to LLM
      details: { data: result },                   // For rendering & state
    };
  },

  // Optional: Custom rendering
  renderCall(args, theme) { ... },
  renderResult(result, options, theme) { ... },
});
```

**Important:** Use `StringEnum` from `shortcutxl/ai` for string enums. `Type.Union`/`Type.Literal` doesn't work with Google's API.

### Overriding Built-in Tools

Extensions can override built-in tools (`read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`) by registering a tool with the same name. Interactive mode displays a warning when this happens.

```bash
# Extension's read tool replaces built-in read
shortcut -e ./tool-override.ts
```

See [examples/extensions/tool-override.ts](../examples/extensions/tool-override.ts) for a complete example that overrides `read` with logging and access control.

**Rendering:** If your override doesn't provide custom `renderCall`/`renderResult` functions, the built-in renderer is used automatically (syntax highlighting, diffs, etc.). This lets you wrap built-in tools for logging or access control without reimplementing the UI.

**Your implementation must match the exact result shape**, including the `details` type. The UI and session logic depend on these shapes for rendering and state tracking.

Built-in tool implementations:

- [read.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-runtime/src/core/tools/read.ts) - `ReadToolDetails`
- [bash.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-runtime/src/core/tools/bash.ts) - `BashToolDetails`
- [edit.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-runtime/src/core/tools/edit.ts)
- [write.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-runtime/src/core/tools/write.ts)
- [grep.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-runtime/src/core/tools/grep.ts) - `GrepToolDetails`
- [find.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-runtime/src/core/tools/find.ts) - `FindToolDetails`
- [ls.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-runtime/src/core/tools/ls.ts) - `LsToolDetails`

### Output Truncation

**Tools MUST truncate their output** to avoid overwhelming the LLM context. Large outputs can cause:

- Context overflow errors (prompt too long)
- Compaction failures
- Degraded model performance

The built-in limit is **50KB** (~10k tokens) and **2000 lines**, whichever is hit first. Use the exported truncation utilities:

```typescript
import {
  truncateHead,      // Keep first N lines/bytes (good for file reads, search results)
  truncateTail,      // Keep last N lines/bytes (good for logs, command output)
  truncateLine,      // Truncate a single line to maxBytes with ellipsis
  formatSize,        // Human-readable size (e.g., "50KB", "1.5MB")
  DEFAULT_MAX_BYTES, // 50KB
  DEFAULT_MAX_LINES, // 2000
} from "shortcutxl/app";

async execute(toolCallId, params, signal, onUpdate, ctx) {
  const output = await runCommand();

  // Apply truncation
  const truncation = truncateHead(output, {
    maxLines: DEFAULT_MAX_LINES,
    maxBytes: DEFAULT_MAX_BYTES,
  });

  let result = truncation.content;

  if (truncation.truncated) {
    // Write full output to temp file
    const tempFile = writeTempFile(output);

    // Inform the LLM where to find complete output
    result += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`;
    result += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
    result += ` Full output saved to: ${tempFile}]`;
  }

  return { content: [{ type: "text", text: result }] };
}
```

**Key points:**

- Use `truncateHead` for content where the beginning matters (search results, file reads)
- Use `truncateTail` for content where the end matters (logs, command output)
- Always inform the LLM when output is truncated and where to find the full version
- Document the truncation limits in your tool's description

See [examples/extensions/truncated-tool.ts](../examples/extensions/truncated-tool.ts) for a complete example wrapping `rg` (ripgrep) with proper truncation.

### Multiple Tools

One extension can register multiple tools with shared state:

```typescript
export default function (shortcut: ExtensionAPI) {
  let connection = null;

  shortcut.registerTool({ name: "db_connect", ... });
  shortcut.registerTool({ name: "db_query", ... });
  shortcut.registerTool({ name: "db_close", ... });

  shortcut.on("session_shutdown", async () => {
    connection?.close();
  });
}
```

### Custom Rendering

Tools can provide `renderCall` and `renderResult` for custom TUI display. See [tui.md](tui.md) for the full component API and [tool-execution.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-runtime/src/shell/tools/runtime/tool-execution.ts) for how built-in tools render.

Tool output is wrapped in a `Box` that handles padding and background. Your render methods return `Component` instances (typically `Text`).

#### renderCall

Renders the tool call (before/during execution):

```typescript
import { Text } from "shortcutxl/tui";

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

Renders the tool result:

```typescript
renderResult(result, { expanded, isPartial }, theme) {
  // Handle streaming
  if (isPartial) {
    return new Text(theme.fg("warning", "Processing..."), 0, 0);
  }

  // Handle errors
  if (result.details?.error) {
    return new Text(theme.fg("error", `Error: ${result.details.error}`), 0, 0);
  }

  // Normal result - support expanded view (Ctrl+O)
  let text = theme.fg("success", "✓ Done");
  if (expanded && result.details?.items) {
    for (const item of result.details.items) {
      text += "\n  " + theme.fg("dim", item);
    }
  }
  return new Text(text, 0, 0);
}
```

#### Keybinding Hints

Use `keyHint()` to display keybinding hints that respect user's keybinding configuration:

```typescript
import { keyHint } from "shortcutxl/shell";

renderResult(result, { expanded }, theme) {
  let text = theme.fg("success", "✓ Done");
  if (!expanded) {
    text += ` (${keyHint("expandTools", "to expand")})`;
  }
  return new Text(text, 0, 0);
}
```

Available functions:

- `keyHint(action, description)` - Editor actions (e.g., `"expandTools"`, `"selectConfirm"`)
- `appKeyHint(keybindings, action, description)` - App actions (requires `KeybindingsManager`)
- `editorKey(action)` - Get raw key string for editor action
- `rawKeyHint(key, description)` - Format a raw key string

#### Best Practices

- Use `Text` with padding `(0, 0)` - the Box handles padding
- Use `\n` for multi-line content
- Handle `isPartial` for streaming progress
- Support `expanded` for detail on demand
- Keep default view compact

#### Fallback

If `renderCall`/`renderResult` is not defined or throws:

- `renderCall`: Shows tool name
- `renderResult`: Shows raw text from `content`

## Custom UI

Extensions can interact with users via `ctx.ui` methods and customize how messages/tools render.

**For custom components, see [tui.md](tui.md)** which has copy-paste patterns for:

- Selection dialogs (SelectList)
- Async operations with cancel (BorderedLoader)
- Settings toggles (SettingsList)

### Dialogs

```typescript
// Select from options
const choice = await ctx.ui.select('Pick one:', ['A', 'B', 'C']);

// Confirm dialog
const ok = await ctx.ui.confirm('Delete?', 'This cannot be undone');

// Text input
const name = await ctx.ui.input('Name:', 'placeholder');

// Notification (non-blocking)
ctx.ui.notify('Done!', 'info'); // "info" | "warning" | "error"
```

#### Timed Dialogs with Countdown

Dialogs support a `timeout` option that auto-dismisses with a live countdown display:

```typescript
// Dialog shows "Title (5s)" → "Title (4s)" → ... → auto-dismisses at 0
const confirmed = await ctx.ui.confirm(
  'Timed Confirmation',
  'This dialog will auto-cancel in 5 seconds. Confirm?',
  { timeout: 5000 }
);

if (confirmed) {
  // User confirmed
} else {
  // User cancelled or timed out
}
```

**Return values on timeout:**

- `select()` returns `undefined`
- `confirm()` returns `false`
- `input()` returns `undefined`

#### Manual Dismissal with AbortSignal

For more control (e.g., to distinguish timeout from user cancel), use `AbortSignal`:

```typescript
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);

const confirmed = await ctx.ui.confirm(
  'Timed Confirmation',
  'This dialog will auto-cancel in 5 seconds. Confirm?',
  { signal: controller.signal }
);

clearTimeout(timeoutId);

if (confirmed) {
  // User confirmed
} else if (controller.signal.aborted) {
  // Dialog timed out
} else {
  // User cancelled (pressed Escape or selected "No")
}
```

See [examples/extensions/timed-confirm.ts](../examples/extensions/timed-confirm.ts) for complete examples.

### Custom Components

For complex UI, use `ctx.ui.custom()`. This temporarily replaces the editor with your component until `done()` is called:

```typescript
import { Text, type Component } from 'shortcutxl/tui';

const result = await ctx.ui.custom<boolean>((tui, theme, keybindings, done) => {
  const text = new Text('Press Enter to confirm, Escape to cancel', 1, 1);

  text.onKey = (key) => {
    if (key === 'return') done(true);
    if (key === 'escape') done(false);
    return true;
  };

  return text;
});

if (result) {
  // User pressed Enter
}
```

The callback receives:

- `tui` - TUI instance (for screen dimensions, focus management)
- `theme` - Current theme for styling
- `keybindings` - App keybinding manager (for checking shortcuts)
- `done(value)` - Call to close component and return value

See [tui.md](tui.md) for the full component API.

#### Overlay Mode (Experimental)

Pass `{ overlay: true }` to render the component as a floating modal on top of existing content, without clearing the screen:

```typescript
const result = await ctx.ui.custom<string | null>(
  (tui, theme, keybindings, done) => new MyOverlayComponent({ onClose: done }),
  { overlay: true }
);
```

For advanced positioning (anchors, margins, percentages, responsive visibility), pass `overlayOptions`. Use `onHandle` to control visibility programmatically:

```typescript
const result = await ctx.ui.custom<string | null>(
  (tui, theme, keybindings, done) => new MyOverlayComponent({ onClose: done }),
  {
    overlay: true,
    overlayOptions: { anchor: 'top-right', width: '50%', margin: 2 },
    onHandle: (handle) => {
      /* handle.setHidden(true/false) */
    }
  }
);
```

See [tui.md](tui.md) for the full `OverlayOptions` API.

### Theme Colors

All render functions receive a `theme` object. See [themes.md](themes.md) for creating custom themes and the full color palette.

```typescript
// Foreground colors
theme.fg('toolTitle', text); // Tool names
theme.fg('accent', text); // Highlights
theme.fg('success', text); // Success (green)
theme.fg('error', text); // Errors (red)
theme.fg('warning', text); // Warnings (yellow)
theme.fg('muted', text); // Secondary text
theme.fg('dim', text); // Tertiary text

// Text styles
theme.bold(text);
theme.italic(text);
theme.strikethrough(text);
```

For syntax highlighting in custom tool renderers:

```typescript
import { highlightCode, getLanguageFromPath } from 'shortcutxl/shell';

// Highlight code with explicit language
const highlighted = highlightCode('const x = 1;', 'typescript', theme);

// Auto-detect language from file path
const lang = getLanguageFromPath('/path/to/file.rs'); // "rust"
const highlighted = highlightCode(code, lang, theme);
```

## Error Handling

- Extension errors are logged, agent continues
- `tool_call` errors block the tool (fail-safe)
- Tool `execute` errors are reported to the LLM with `isError: true`

## Mode Behavior

| Mode                 | UI Methods    | Notes                                          |
| -------------------- | ------------- | ---------------------------------------------- |
| Interactive          | Full TUI      | Normal operation                               |
| RPC (`--mode rpc`)   | JSON protocol | Host handles UI, see [rpc.md](rpc.md)          |
| JSON (`--mode json`) | No-op         | Event stream to stdout, see [json.md](json.md) |
| Print (`-p`)         | No-op         | Extensions run but can't prompt                |

In non-interactive modes, check `ctx.hasUI` before using UI methods.

## Examples Reference

All examples in [examples/extensions/](../examples/extensions/).

| Example                       | Description                    | Key APIs                                                      |
| ----------------------------- | ------------------------------ | ------------------------------------------------------------- |
| **Tools**                     |                                |                                                               |
| `hello.ts`                    | Minimal tool registration      | `registerTool`                                                |
| `question.ts`                 | Tool with user interaction     | `registerTool`, `ui.select`                                   |
| `questionnaire.ts`            | Multi-step wizard tool         | `registerTool`, `ui.custom`                                   |
| `todo.ts`                     | Stateful tool with persistence | `registerTool`, `appendEntry`, `renderResult`, session events |
| `dynamic-tools.ts`            | Register tools after startup   | `registerTool`, `session_start`, `registerCommand`            |
| `truncated-tool.ts`           | Output truncation              | `registerTool`, `truncateHead`                                |
| `tool-override.ts`            | Override built-in read tool    | `registerTool` (same name as built-in)                        |
| **Commands**                  |                                |                                                               |
| `commands.ts`                 | Slash command examples         | `registerCommand`                                             |
| `summarize.ts`                | Conversation summary           | `registerCommand`, `ui.custom`                                |
| `handoff.ts`                  | Cross-session handoff          | `registerCommand`, `ctx.newSession()`                         |
| `qna.ts`                      | Q&A extraction                 | `registerCommand`, `ui.custom`                                |
| `reload-runtime.ts`           | Reload extensions              | `registerCommand`, `ctx.reload()`                             |
| `shutdown-command.ts`         | Graceful shutdown              | `registerCommand`, `ctx.shutdown()`                           |
| `tools.ts`                    | Toggle tools on/off            | `registerCommand`, `ui.custom`                                |
| **Events & Gates**            |                                |                                                               |
| `permission-gate.ts`          | Block dangerous commands       | `on("tool_call")`, `ui.confirm`                               |
| `protected-paths.ts`          | Block writes to paths          | `on("tool_call")`                                             |
| `confirm-destructive.ts`      | Confirm session changes        | `on("session_before_switch")`                                 |
| `dirty-repo-guard.ts`         | Warn on dirty git repo         | `on("session_before_*")`                                      |
| **Compaction & Sessions**     |                                |                                                               |
| `custom-compaction.ts`        | Custom compaction summary      | `on("session_before_compact")`                                |
| `trigger-compact.ts`          | Trigger compaction             | `ctx.compact()`                                               |
| `git-checkpoint.ts`           | Git stash on session events    | session lifecycle events                                      |
| `auto-commit-on-exit.ts`      | Commit on shutdown             | `on("session_shutdown")`                                      |
| **UI**                        |                                |                                                               |
| `notify.ts`                   | Desktop notifications          | `ui.notify`                                                   |
| `timed-confirm.ts`            | Dialogs with timeout           | `ui.confirm` with timeout/signal                              |
| `overlay-test.ts`             | Overlay components             | `ui.custom` with overlay                                      |
| `widget-placement.ts`         | Widget placement               | `ui.custom`                                                   |
| `modal-editor.ts`             | Vim-style editor               | `ui.custom`                                                   |
| `rainbow-editor.ts`           | Custom editor styling          | `ui.custom`                                                   |
| **Games**                     |                                |                                                               |
| `snake.ts`                    | Snake game                     | `registerCommand`, `ui.custom`                                |
| `space-invaders.ts`           | Space Invaders                 | `registerCommand`, `ui.custom`                                |
| `doom-overlay/`               | Doom overlay                   | `ui.custom` with overlay                                      |
| **Sandbox & Tools**           |                                |                                                               |
| `sandbox/`                    | Sandboxed execution            | Tool operations                                               |
| `bash-spawn-hook.ts`          | Bash spawn hook                | `createBashTool`, `spawnHook`                                 |
| `interactive-shell.ts`        | Persistent shell               | `on("user_bash")`                                             |
| `inline-bash.ts`              | Inline bash expansion          | `on("tool_call")`                                             |
| **Providers**                 |                                |                                                               |
| `custom-provider-anthropic/`  | Custom Anthropic proxy         | `registerProvider`                                            |
| `custom-provider-gitlab-duo/` | GitLab Duo provider            | `registerProvider` with OAuth                                 |
| `custom-provider-qwen-cli/`   | Qwen CLI provider              | `registerProvider` with OAuth                                 |
| **Misc**                      |                                |                                                               |
| `claude-code.ts`              | Claude Code integration        | `registerTool`, `registerCommand`                             |
| `with-deps/`                  | npm dependencies               | Package structure                                             |
| `dynamic-resources/`          | Resource discovery             | `resources_discover`                                          |
