# Hot Sheet Plugin Development Guide
This guide is designed for AI coding assistants (Claude, Copilot, etc.) and human developers. It provides everything needed to build a Hot Sheet plugin from scratch.
## Plugin Structure
A plugin is a directory in `~/.hotsheet/plugins/` containing:
```
my-plugin/
manifest.json # Plugin metadata, preferences, and UI layout
index.js # Entry point (ESM module)
```
Or if using TypeScript:
```
my-plugin/
manifest.json
src/
types.ts # Standalone type definitions (copy from below)
index.ts # Entry point
tsconfig.json
```
Build the TypeScript to `index.js` in the plugin root.
## Manifest Format
```json
{
"id": "my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"description": "What this plugin does",
"author": "Author Name",
"entry": "index.js",
"icon": "",
"preferences": [ ... ],
"configLayout": [ ... ]
}
```
### Required Fields
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Unique plugin identifier (e.g. `linear-issues`) |
| `name` | string | Human-readable display name |
| `version` | string | Semver version |
### Optional Fields
| Field | Type | Description |
|-------|------|-------------|
| `description` | string | Short description shown in plugin list |
| `author` | string | Author name |
| `entry` | string | Entry point filename (default: `index.js`) |
| `icon` | string | Inline SVG string (14x14 viewBox recommended, shown on synced tickets) |
| `preferences` | array | Configurable settings (see below) |
| `configLayout` | array | Config dialog layout (see below) |
## Preferences
Each preference defines a configurable setting:
```json
{
"key": "api_token",
"label": "API Token",
"type": "string",
"required": true,
"secret": true,
"scope": "global",
"description": "Your personal API token"
}
```
### Preference Fields
| Field | Type | Values | Description |
|-------|------|--------|-------------|
| `key` | string | | Setting identifier |
| `label` | string | | Display label |
| `type` | string | `string`, `boolean`, `number`, `select`, `dropdown`, `combo` | Input type |
| `default` | any | | Default value |
| `description` | string | | Help text shown below the label |
| `required` | boolean | | Shows asterisk, affects "Needs Configuration" status |
| `secret` | boolean | | Masks the input (password field) |
| `scope` | string | `global`, `project` | `global` = shared across projects (stored in `~/.hotsheet/plugin-config.json`). `project` = per-project (stored in project DB). Default: `project` |
| `options` | array | `[{ value, label }]` | For `select`, `dropdown`, and `combo` types |
**Type details:**
- `string` — text input (password if `secret: true`)
- `boolean` — checkbox
- `number` — numeric input
- `select` / `dropdown` — dropdown with predefined choices
- `combo` — dropdown with predefined choices AND free-text entry
## Config Layout
The `configLayout` array controls how the config dialog is structured. If omitted, preferences are shown in a flat list.
```json
"configLayout": [
{ "type": "preference", "key": "api_token" },
{ "type": "divider" },
{ "type": "preference", "key": "workspace" },
{ "type": "preference", "key": "project" },
{ "type": "spacer" },
{ "type": "label", "id": "connection-status", "text": "Not tested" },
{ "type": "button", "id": "test-btn", "label": "Test Connection", "action": "test_connection" },
{ "type": "group", "title": "Advanced", "collapsed": true, "items": [
{ "type": "preference", "key": "custom_field_mapping" }
]}
]
```
### Layout Item Types
| Type | Fields | Description |
|------|--------|-------------|
| `preference` | `key` | Renders the preference input for the given key |
| `divider` | | Horizontal line |
| `spacer` | | Vertical gap (12px) |
| `label` | `id`, `text`, `color?` | Dynamic text label. `color` is one of `default`, `success`, `error`, `warning`, `transient`. Update via `context.updateConfigLabel` |
| `button` | `id`, `label`, `action`, `icon?`, `style?` | Clickable button that triggers `onAction` |
| `group` | `title`, `collapsed?`, `items` | Collapsible group containing other layout items |
## Plugin Entry Point
The entry point must export an `activate` function. It can optionally export `onAction` and `validateField`.
```typescript
import type { PluginContext, TicketingBackend } from './types.js';
let context: PluginContext;
export async function activate(ctx: PluginContext): Promise {
context = ctx;
// Read settings
const token = await ctx.getSetting('api_token');
const workspace = await ctx.getSetting('workspace');
// Register UI elements (toolbar buttons, etc.)
ctx.registerUI([
{
id: 'sync-button',
type: 'button',
location: 'toolbar',
icon: '',
title: 'Sync',
action: 'sync',
},
]);
// Return a TicketingBackend if this plugin syncs with an external system
// Return void/undefined if this plugin doesn't sync tickets
return {
id: 'my-plugin',
name: 'My Plugin',
capabilities: { create: true, update: true, delete: true, incrementalPull: true, comments: true, syncableFields: ['title', 'details', 'category', 'priority', 'status', 'tags', 'up_next'] },
fieldMappings: { category: { toRemote: {}, toLocal: {} }, priority: { toRemote: {}, toLocal: {} }, status: { toRemote: {}, toLocal: {} } },
// ... implement methods below
};
}
export async function onAction(actionId: string, actionContext: { ticketIds?: number[]; value?: unknown }): Promise {
if (actionId === 'sync') return { redirect: 'sync' };
if (actionId === 'test_connection') {
// test and update label (third arg is one of: default | success | error | warning | transient)
context.updateConfigLabel('connection-status', 'Connected', 'success');
return { connected: true };
}
return null;
}
export async function validateField(key: string, value: string): Promise<{ status: string; message: string } | null> {
if (key === 'api_token' && !value) return { status: 'error', message: 'Required' };
return null;
}
```
## PluginContext API
The `context` object passed to `activate`:
| Method | Description |
|--------|-------------|
| `getSetting(key)` | Read a preference value (respects scope: global vs project) |
| `setSetting(key, value)` | Write a preference value |
| `log(level, message)` | Log attributed to this plugin (`'info'`, `'warn'`, `'error'`) |
| `registerUI(elements)` | Register UI elements (toolbar buttons, etc.) |
| `updateConfigLabel(labelId, text, color?)` | Dynamically update a label in the config dialog. `color` is `default` \| `success` \| `error` \| `warning` \| `transient` |
## UI Extension Points
Plugins can register UI elements at these locations:
| Location | Scope | Description |
|----------|-------|-------------|
| `toolbar` | Project | Header toolbar |
| `status_bar` | Project | Footer status bar |
| `sidebar_actions_top` | Project | Sidebar, before first action |
| `sidebar_actions_bottom` | Project | Sidebar, after last action |
| `detail_top` | Ticket | Detail panel, above fields |
| `detail_bottom` | Ticket | Detail panel, below attachments |
| `batch_menu` | Selection | Batch toolbar "..." menu (not yet rendered) |
| `context_menu` | Selection | Right-click ticket context menu |
### Element Types
**Button:**
```javascript
{ id: 'my-btn', type: 'button', location: 'toolbar', label: 'Click Me', icon: '', title: 'Tooltip', action: 'my_action', style: 'default' | 'primary' | 'danger' }
```
**Toggle, Switch, Link, Segmented Control** — see `src/plugins/types.ts` for full definitions.
When a user clicks a button, the app calls `POST /api/plugins/:id/action` with `{ actionId }`, which invokes the plugin's `onAction` handler. Return `{ redirect: 'sync' }` to trigger a sync operation, or return `{ message: '...' }` to show a brief toast notification to the user.
**Location rendering:** Toolbar buttons show icon only (compact). All other locations (status_bar, sidebar, detail, context_menu) show icon + label together. `button` and `link` types are rendered; `toggle`, `switch`, and `segmented_control` are declared but not yet rendered. `batch_menu` is defined in the type system but not yet rendered in the UI.
---
# Part 1: Building a Ticketing System Plugin
This section covers building a plugin that syncs tickets with an external system (GitHub Issues, Linear, Jira, Trello, etc.).
## TicketingBackend Interface
Return this from `activate()` to enable bidirectional sync:
```typescript
interface TicketingBackend {
id: string; // Must match the plugin's manifest id
name: string; // Display name (e.g. "Linear")
capabilities: {
create: boolean; // Can create tickets remotely
update: boolean; // Can update tickets remotely
delete: boolean; // Can delete/close tickets remotely
incrementalPull: boolean; // Supports pulling changes since a date
comments?: boolean; // Supports comment/note sync
syncableFields: ('title' | 'details' | 'category' | 'priority' | 'status' | 'tags' | 'up_next')[];
};
fieldMappings: {
category: { toRemote: Record, toLocal: Record },
priority: { toRemote: Record, toLocal: Record },
status: { toRemote: Record, toLocal: Record },
};
// Required methods:
createRemote(ticket: Ticket): Promise; // Returns remote ID
updateRemote(remoteId: string, changes: Partial): Promise;
deleteRemote(remoteId: string): Promise;
pullChanges(since: Date | null): Promise;
checkConnection(): Promise<{ connected: boolean; error?: string }>;
// Optional methods:
getRemoteTicket?(remoteId: string): Promise;
getRemoteUrl?(remoteId: string): string | null; // URL to view ticket remotely
shouldAutoSync?(ticket: Ticket): boolean; // Auto-push new tickets?
getComments?(remoteId: string): Promise;
createComment?(remoteId: string, text: string): Promise;
updateComment?(remoteId: string, commentId: string, text: string): Promise;
deleteComment?(remoteId: string, commentId: string): Promise;
uploadAttachment?(filename: string, content: Buffer, mimeType: string): Promise;
// HS-8952 — download a remote image referenced in a synced body so it can be
// stored as a local attachment. Return null on auth failure / 404 / non-image.
// context.remoteId identifies the item the image came from — needed when a host
// (e.g. GitHub user-attachments) can't be fetched directly and must be resolved
// via the item's rendered body to a fetchable signed URL.
downloadAttachment?(
url: string,
context?: { remoteId?: string },
): Promise<{ content: Buffer; filename: string; mimeType: string } | null>;
}
```
## How Sync Works
The sync engine handles the complexity. Your plugin just needs to implement the interface methods.
### Pull (remote -> local)
1. Engine calls `pullChanges(since)` — return all issues modified since the date
2. For each `RemoteChange`, the engine checks if a local ticket is already linked
3. **New remote ticket**: creates a local ticket
4. **Existing linked ticket**: compares timestamps, applies remote changes if only remote modified
5. **Both modified**: creates a conflict record (user resolves in UI)
### Push (local -> remote)
1. Engine compares each synced ticket's `updated_at` with the sync record's `local_updated_at`
2. If the local ticket was modified since last sync, calls `updateRemote(remoteId, allFields)` with the full current field values
3. Create/delete operations are tracked via an outbox queue
### Comments
If `capabilities.comments` is true and comment methods are implemented, the sync engine runs a three-way merge using `last_synced_text` in the `note_sync` table:
**Create (bidirectional):**
1. New remote comments (unmapped) → create local notes. Text-based dedup: if a local note already has identical text, it's linked instead of duplicated.
2. New local notes (unmapped) → call `createComment(remoteId, text)`. Text-based dedup applies in reverse.
**Edit (bidirectional):**
3. For each existing mapping, compares current local text and remote text against the `last_synced_text` baseline.
4. Only local changed → calls `updateComment(remoteId, commentId, newText)`.
5. Only remote changed → updates the local note text.
6. Both changed → push-wins (local overwrites remote via `updateComment`).
**Delete (bidirectional):**
7. Local note deleted (mapping exists, note gone) → calls `deleteComment(remoteId, commentId)`.
8. Remote comment deleted (mapping exists, comment gone) → removes the local note.
Attachment mappings (note IDs with `att_` prefix) are skipped by the comment sync.
### Attachments
If `uploadAttachment` is implemented:
1. Engine reads local attachments for each synced ticket
2. Calls `uploadAttachment(filename, content, mimeType)` → returns a URL
3. Posts a markdown comment on the remote issue with the file link
4. Returned URLs should be permanent (not short-lived tokens) — the host may cache or proxy them
## Field Mappings
Map between Hot Sheet's field values and the remote system's values.
**Hot Sheet ticket fields:**
- `category`: `issue`, `bug`, `feature`, `requirement_change`, `task`, `investigation` (customizable)
- `priority`: `highest`, `high`, `default`, `low`, `lowest`
- `status`: `not_started`, `started`, `completed`, `verified`, `backlog`, `archive`
- `tags`: JSON array of strings
- `up_next`: boolean
**Example mapping for Linear:**
```typescript
fieldMappings: {
category: {
toRemote: { bug: 'Bug', feature: 'Feature', task: 'Task', issue: 'Issue' },
toLocal: { 'Bug': 'bug', 'Feature': 'feature', 'Task': 'task', 'Issue': 'issue' },
},
priority: {
toRemote: { highest: '1', high: '2', default: '3', low: '4', lowest: '0' },
toLocal: { '1': 'highest', '2': 'high', '3': 'default', '4': 'low', '0': 'lowest' },
},
status: {
toRemote: { not_started: 'Todo', started: 'In Progress', completed: 'Done', verified: 'Done' },
toLocal: { 'Todo': 'not_started', 'In Progress': 'started', 'Done': 'completed', 'Backlog': 'backlog' },
},
}
```
## RemoteChange Format
`pullChanges` must return an array of:
```typescript
{
remoteId: string; // The remote system's ID for this ticket
fields: { // Mapped to Hot Sheet field values (use toLocal mappings)
title: string;
details: string;
category: string; // Already mapped to local value
priority: string; // Already mapped to local value
status: string; // Already mapped to local value
tags: string[];
up_next: boolean;
};
remoteUpdatedAt: Date; // When the remote ticket was last modified
deleted?: boolean; // True if the remote ticket was deleted
}
```
## Complete Example: Linear Plugin Skeleton
```typescript
import type { PluginContext, RemoteChange, RemoteTicketFields, Ticket, TicketingBackend } from './types.js';
let context: PluginContext;
export async function activate(ctx: PluginContext): Promise {
context = ctx;
const apiKey = await ctx.getSetting('api_key');
const teamId = await ctx.getSetting('team_id');
ctx.registerUI([
{ id: 'sync-btn', type: 'button', location: 'toolbar', icon: '', title: 'Sync with Linear', action: 'sync' },
]);
async function linearFetch(query: string, variables?: Record) {
const res = await fetch('https://api.linear.app/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': apiKey ?? '' },
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`Linear API error: ${res.status}`);
return res.json();
}
return {
id: 'linear',
name: 'Linear',
capabilities: {
create: true, update: true, delete: false,
incrementalPull: true, comments: true,
syncableFields: ['title', 'details', 'category', 'priority', 'status', 'tags', 'up_next'],
},
fieldMappings: {
category: { toRemote: { bug: 'Bug', feature: 'Feature' }, toLocal: { 'Bug': 'bug', 'Feature': 'feature' } },
priority: { toRemote: { highest: '1', high: '2', default: '3', low: '4', lowest: '0' }, toLocal: { '1': 'highest', '2': 'high', '3': 'default', '4': 'low', '0': 'lowest' } },
status: { toRemote: { not_started: 'Todo', started: 'In Progress', completed: 'Done' }, toLocal: { 'Todo': 'not_started', 'In Progress': 'started', 'Done': 'completed' } },
},
async createRemote(ticket) {
const data = await linearFetch(`mutation { issueCreate(input: { teamId: "${teamId}", title: "${ticket.title}", description: "${ticket.details}" }) { issue { id } } }`);
return data.data.issueCreate.issue.id;
},
async updateRemote(remoteId, changes) {
const input: Record = {};
if (changes.title) input.title = changes.title;
if (changes.details) input.description = changes.details;
// ... map other fields
await linearFetch(`mutation { issueUpdate(id: "${remoteId}", input: ${JSON.stringify(input)}) { issue { id } } }`);
},
async deleteRemote(remoteId) {
await linearFetch(`mutation { issueArchive(id: "${remoteId}") { success } }`);
},
async pullChanges(since) {
const filter = since ? `updatedAt: { gte: "${since.toISOString()}" }` : '';
const data = await linearFetch(`{ issues(filter: { team: { id: { eq: "${teamId}" } }, ${filter} }) { nodes { id title description state { name } priority updatedAt labels { nodes { name } } } } }`);
return data.data.issues.nodes.map((issue: any) => ({
remoteId: issue.id,
fields: {
title: issue.title,
details: issue.description ?? '',
status: this.fieldMappings.status.toLocal[issue.state.name] ?? 'not_started',
priority: this.fieldMappings.priority.toLocal[String(issue.priority)] ?? 'default',
category: 'issue',
tags: issue.labels.nodes.map((l: any) => l.name),
up_next: false,
},
remoteUpdatedAt: new Date(issue.updatedAt),
}));
},
async checkConnection() {
try {
await linearFetch('{ viewer { id } }');
return { connected: true };
} catch (e) {
return { connected: false, error: (e as Error).message };
}
},
getRemoteUrl(remoteId) {
return `https://linear.app/issue/${remoteId}`;
},
};
}
export async function onAction(actionId: string) {
if (actionId === 'sync') return { redirect: 'sync' };
if (actionId === 'test_connection') {
// handled by checkConnection via the status endpoint
return null;
}
return null;
}
```
---
# Part 2: Building Non-Ticketing Plugins
Plugins don't have to sync with external systems. They can add toolbar buttons, custom actions, sidebar widgets, or other functionality. Just return `void` from `activate()` instead of a `TicketingBackend`.
## Examples of Non-Ticketing Plugins
- **Time tracker** — start/stop timer on tickets, log hours
- **Export plugin** — export tickets as CSV, PDF, or custom format
- **Notification plugin** — send Slack/Discord/email notifications on ticket changes
- **AI assistant** — auto-categorize or auto-prioritize tickets
- **Custom views** — add computed sidebar stats or dashboards
- **Integration bridge** — post to webhooks, trigger CI/CD pipelines
## Minimal Non-Ticketing Plugin
```typescript
import type { PluginContext } from './types.js';
export async function activate(context: PluginContext): Promise {
context.registerUI([
{
id: 'export-csv',
type: 'button',
location: 'toolbar',
label: 'Export CSV',
icon: '',
action: 'export',
},
]);
}
export async function onAction(actionId: string, context: { ticketIds?: number[] }): Promise {
if (actionId === 'export') {
// The action runs on the server — you have full Node.js access
// Return data to the client or trigger side effects
return { message: 'Export complete', url: '/path/to/exported/file.csv' };
}
return null;
}
```
## Standalone Types File
Copy this into your plugin's `src/types.ts` to build independently from the Hot Sheet package:
```typescript
export interface PluginUIElement {
id: string;
type: string;
location: string;
[key: string]: unknown;
}
export interface FieldValidation {
status: 'error' | 'warning' | 'success';
message: string;
}
export type ConfigLabelColor = 'default' | 'success' | 'error' | 'warning' | 'transient';
export interface PluginContext {
config: Record;
log(level: 'info' | 'warn' | 'error', message: string): void;
getSetting(key: string): Promise;
setSetting(key: string, value: string): Promise;
registerUI(elements: PluginUIElement[]): void;
updateConfigLabel(labelId: string, text: string, color?: ConfigLabelColor): void;
}
export interface HotSheetPlugin {
activate(context: PluginContext): Promise;
deactivate?(): Promise;
onAction?(actionId: string, params: { ticketIds?: number[]; value?: unknown }): Promise;
validateField?(key: string, value: string): Promise;
}
export interface TicketingBackend {
id: string;
name: string;
capabilities: BackendCapabilities;
fieldMappings: FieldMappings;
createRemote(ticket: Ticket): Promise;
updateRemote(remoteId: string, changes: Partial): Promise;
deleteRemote(remoteId: string): Promise;
pullChanges(since: Date | null): Promise;
getRemoteTicket?(remoteId: string): Promise;
checkConnection(): Promise<{ connected: boolean; error?: string }>;
getRemoteUrl?(remoteId: string): string | null;
shouldAutoSync?(ticket: Ticket): boolean;
getComments?(remoteId: string): Promise;
createComment?(remoteId: string, text: string): Promise;
updateComment?(remoteId: string, commentId: string, text: string): Promise;
deleteComment?(remoteId: string, commentId: string): Promise;
uploadAttachment?(filename: string, content: Buffer, mimeType: string): Promise;
}
export interface BackendCapabilities {
create: boolean;
update: boolean;
delete: boolean;
incrementalPull: boolean;
syncableFields: (keyof RemoteTicketFields)[];
comments?: boolean;
}
export interface FieldMappings {
category: FieldMap;
priority: FieldMap;
status: FieldMap;
}
export interface FieldMap {
toRemote: Record;
toLocal: Record;
}
export interface RemoteTicketFields {
title: string;
details: string;
category: string;
priority: string;
status: string;
tags: string[];
up_next: boolean;
}
export interface RemoteChange {
remoteId: string;
fields: Partial;
remoteUpdatedAt: Date;
deleted?: boolean;
}
export interface RemoteComment {
id: string;
text: string;
createdAt: Date;
updatedAt: Date;
}
export interface Ticket {
id: number;
ticket_number: string;
title: string;
details: string;
category: string;
priority: string;
status: string;
up_next: boolean;
tags: string;
[key: string]: unknown;
}
```
## Installation
Place the plugin directory in `~/.hotsheet/plugins/` and restart Hot Sheet. The plugin will be automatically discovered and loaded.
For development, you can symlink your plugin directory:
```bash
ln -s /path/to/my-plugin ~/.hotsheet/plugins/my-plugin
```
## Reference Implementation
See the GitHub Issues plugin for a complete working example:
- Source: `plugins/github-issues/src/index.ts`
- Manifest: `plugins/github-issues/manifest.json`
- Types: `plugins/github-issues/src/types.ts`