Code Examples
This page contains complete, working plugin examples that demonstrate common patterns. Each example includes a manifest file and the relevant source code. Use these as starting points for your own plugins.
Hello World Plugin
The simplest possible Ghosty plugin. Displays a greeting to the current user in a dashboard widget.
Manifest
name: hello-world
version: 1.0.0
display_name: Hello World
description: A simple greeting widget.
author: your-username
permissions:
- user.profile
views:
widget:
entry: src/widget.ts
default_size: small
resizable: falseWidget source
// src/widget.ts
import { ghosty } from "@ghosty/sdk";
export function render(container: HTMLElement): void {
const name = ghosty.user.displayName ?? "World";
const theme = ghosty.widget.getTheme();
container.innerHTML = `
<div style="
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 16px;
font-family: system-ui, sans-serif;
color: ${theme.colors.foreground};
background: ${theme.colors.card};
border-radius: 12px;
">
<span style="font-size: 32px; margin-bottom: 8px;">👋</span>
<h2 style="margin: 0; font-size: 16px; font-weight: 600;">
Hello, ${escapeHtml(name)}!
</h2>
<p style="margin: 4px 0 0; font-size: 12px; opacity: 0.7;">
Welcome to Ghosty
</p>
</div>
`;
}
/** Prevent XSS by escaping user-controlled strings. */
function escapeHtml(str: string): string {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}Always escape user-controlled strings before inserting them into HTML.
The escapeHtml helper above is a simple and effective pattern.
Weather Widget
A medium-sized widget that fetches and displays current weather conditions for a saved city. Demonstrates storage, network requests, auto-refresh, and theme integration.
Manifest
name: weather-widget
version: 1.0.0
display_name: Weather Widget
description: Current weather conditions for your city.
author: your-username
permissions:
- storage.read
- storage.write
- network.fetch
- user.preferences
allowed_domains:
- api.openweathermap.org
views:
widget:
entry: src/widget.ts
default_size: medium
min_size: small
max_size: large
resizable: true
refresh_interval: 600
ai_context:
summary: Shows current weather conditions for a saved city.
trigger_phrases:
- what's the weather
- show weatherWidget source
// src/widget.ts
import { ghosty, PermissionDeniedError } from "@ghosty/sdk";
// Store API key in plugin storage (set via settings panel)
const API_KEY_STORAGE = "owm_api_key";
const CITY_STORAGE = "city";
interface WeatherData {
city: string;
temp: number;
description: string;
icon: string;
humidity: number;
wind: number;
}
export async function render(container: HTMLElement): Promise<void> {
// Show loading skeleton immediately
renderSkeleton(container);
try {
const weather = await loadWeather();
renderWeather(container, weather);
} catch (error) {
renderError(container, error);
}
}
export async function refresh(container: HTMLElement): Promise<void> {
try {
const weather = await loadWeather();
renderWeather(container, weather);
} catch (error) {
renderError(container, error);
}
}
async function loadWeather(): Promise<WeatherData> {
const apiKey = await ghosty.storage.get(API_KEY_STORAGE);
if (!apiKey) {
throw new Error("API key not configured. Open settings to add your OpenWeatherMap key.");
}
const city = (await ghosty.storage.get(CITY_STORAGE)) ?? "London";
// network.fetch permission required
const response = await fetch(
`https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(String(city))}&appid=${encodeURIComponent(String(apiKey))}&units=metric`
);
if (!response.ok) {
throw new Error(`Weather API returned ${response.status}`);
}
const data = await response.json();
return {
city: data.name,
temp: Math.round(data.main.temp),
description: data.weather[0].description,
icon: data.weather[0].icon,
humidity: data.main.humidity,
wind: Math.round(data.wind.speed),
};
}
function renderSkeleton(container: HTMLElement): void {
const theme = ghosty.widget.getTheme();
container.innerHTML = `
<div style="
padding: 16px;
height: 100%;
background: ${theme.colors.card};
border-radius: 12px;
font-family: system-ui;
color: ${theme.colors.foreground};
">
<div style="width: 60%; height: 16px; background: ${theme.colors.muted}; border-radius: 4px; margin-bottom: 12px;"></div>
<div style="width: 40%; height: 32px; background: ${theme.colors.muted}; border-radius: 4px; margin-bottom: 12px;"></div>
<div style="width: 80%; height: 12px; background: ${theme.colors.muted}; border-radius: 4px;"></div>
</div>
`;
}
function renderWeather(container: HTMLElement, weather: WeatherData): void {
const theme = ghosty.widget.getTheme();
const size = ghosty.widget.getSize();
const isSmall = size.preset === "small";
container.innerHTML = `
<div style="
padding: ${isSmall ? '12px' : '16px'};
height: 100%;
background: ${theme.colors.card};
border-radius: 12px;
font-family: system-ui;
color: ${theme.colors.foreground};
display: flex;
flex-direction: column;
gap: 8px;
">
<div style="font-size: ${isSmall ? '12px' : '14px'}; font-weight: 600; opacity: 0.8;">
${escapeHtml(weather.city)}
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<img
src="https://openweathermap.org/img/wn/${weather.icon}@2x.png"
alt="${escapeHtml(weather.description)}"
style="width: ${isSmall ? '40px' : '48px'}; height: ${isSmall ? '40px' : '48px'};"
/>
<span style="font-size: ${isSmall ? '24px' : '32px'}; font-weight: 700;">
${weather.temp}°C
</span>
</div>
${isSmall ? '' : `
<div style="font-size: 13px; text-transform: capitalize; opacity: 0.7;">
${escapeHtml(weather.description)}
</div>
<div style="display: flex; gap: 16px; font-size: 12px; opacity: 0.6; margin-top: auto;">
<span>Humidity: ${weather.humidity}%</span>
<span>Wind: ${weather.wind} m/s</span>
</div>
`}
</div>
`;
}
function renderError(container: HTMLElement, error: unknown): void {
const theme = ghosty.widget.getTheme();
const message = error instanceof PermissionDeniedError
? "Permission denied. Please grant network access."
: error instanceof Error
? error.message
: "An unexpected error occurred.";
container.innerHTML = `
<div style="
padding: 16px;
height: 100%;
background: ${theme.colors.card};
border-radius: 12px;
font-family: system-ui;
color: ${theme.colors.foreground};
display: flex;
align-items: center;
justify-content: center;
text-align: center;
">
<p style="font-size: 13px; opacity: 0.7; margin: 0;">
${escapeHtml(message)}
</p>
</div>
`;
}
function escapeHtml(str: string): string {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}Todo List Plugin
A fullscreen plugin with internal navigation, CRUD operations, persistent storage, and keyboard shortcuts. Demonstrates the fullscreen API, back button handling, and the settings panel.
Manifest
name: todo-list
version: 1.0.0
display_name: Todo List
description: A simple task manager with categories and due dates.
author: your-username
permissions:
- storage.read
- storage.write
- notifications.show
- notifications.badge
- navigation.fullscreen
views:
widget:
entry: src/widget.ts
default_size: medium
resizable: true
fullscreen:
entry: src/fullscreen.ts
title: Todo List
show_back_button: true
settings_entry: src/settings.ts
ai_context:
summary: A task manager for organising daily to-do items.
capabilities:
- Create, edit, and delete tasks
- Organise tasks by category
- Track completion count
trigger_phrases:
- show my tasks
- add a to-do
- what's on my listData model
// src/types.ts
export interface Todo {
id: string;
title: string;
completed: boolean;
category: string;
createdAt: number;
}
export type TodoList = Todo[];Storage helper
// src/store.ts
import { ghosty } from "@ghosty/sdk";
import type { Todo, TodoList } from "./types";
const STORAGE_KEY = "todos";
export async function loadTodos(): Promise<TodoList> {
const raw = await ghosty.storage.get(STORAGE_KEY);
if (!raw || !Array.isArray(raw)) return [];
return raw as TodoList;
}
export async function saveTodos(todos: TodoList): Promise<void> {
await ghosty.storage.set(STORAGE_KEY, todos);
// Update badge with incomplete count
const incomplete = todos.filter((t) => !t.completed).length;
ghosty.notifications.setBadge(incomplete);
}
export function createTodo(title: string, category: string): Todo {
return {
id: crypto.randomUUID(),
title,
completed: false,
category,
createdAt: Date.now(),
};
}Widget entry (summary view)
// src/widget.ts
import { ghosty } from "@ghosty/sdk";
import { loadTodos } from "./store";
export async function render(container: HTMLElement): Promise<void> {
const todos = await loadTodos();
const incomplete = todos.filter((t) => !t.completed).length;
const total = todos.length;
const theme = ghosty.widget.getTheme();
container.innerHTML = `
<div style="
padding: 16px; height: 100%;
background: ${theme.colors.card};
border-radius: 12px;
font-family: system-ui;
color: ${theme.colors.foreground};
display: flex; flex-direction: column;
justify-content: center; align-items: center;
cursor: pointer;
" id="todo-widget">
<div style="font-size: 32px; font-weight: 700; color: ${theme.colors.primary};">
${incomplete}
</div>
<div style="font-size: 13px; opacity: 0.7; margin-top: 4px;">
${incomplete === 1 ? 'task' : 'tasks'} remaining
</div>
<div style="font-size: 11px; opacity: 0.5; margin-top: 2px;">
${total} total · tap to open
</div>
</div>
`;
container.querySelector("#todo-widget")?.addEventListener("click", () => {
ghosty.navigate.fullscreen();
});
}
export async function refresh(container: HTMLElement): Promise<void> {
await render(container);
}Fullscreen entry
// src/fullscreen.ts
import { ghosty } from "@ghosty/sdk";
import { loadTodos, saveTodos, createTodo } from "./store";
import type { Todo } from "./types";
let todos: Todo[] = [];
export async function render(container: HTMLElement): Promise<void> {
todos = await loadTodos();
renderList(container);
// Register keyboard shortcuts
ghosty.fullscreen.registerShortcut("Ctrl+N", (e) => {
e.preventDefault();
showAddDialog(container);
});
// Handle back button
ghosty.fullscreen.onBackPress(() => true);
}
function renderList(container: HTMLElement): void {
const theme = ghosty.widget.getTheme();
const incomplete = todos.filter((t) => !t.completed);
const completed = todos.filter((t) => t.completed);
container.innerHTML = `
<div style="
max-width: 600px; margin: 0 auto; padding: 24px;
font-family: system-ui; color: ${theme.colors.foreground};
">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;">
<h1 style="margin: 0; font-size: 24px; font-weight: 700;">My Tasks</h1>
<button id="add-btn" style="
padding: 8px 16px; border: none; border-radius: 8px;
background: ${theme.colors.primary}; color: #fff;
font-size: 14px; font-weight: 600; cursor: pointer;
">+ New Task</button>
</div>
${incomplete.length === 0 && completed.length === 0 ? `
<p style="text-align: center; opacity: 0.6; padding: 40px 0;">
No tasks yet. Press <kbd>Ctrl+N</kbd> or tap "New Task" to add one.
</p>
` : ''}
${incomplete.map((todo) => renderTodoItem(todo, theme)).join('')}
${completed.length > 0 ? `
<h2 style="font-size: 14px; font-weight: 600; opacity: 0.5; margin-top: 32px; margin-bottom: 12px;">
Completed (${completed.length})
</h2>
${completed.map((todo) => renderTodoItem(todo, theme)).join('')}
` : ''}
</div>
`;
// Wire up event listeners
container.querySelector("#add-btn")?.addEventListener("click", () => {
showAddDialog(container);
});
container.querySelectorAll("[data-toggle]").forEach((btn) => {
btn.addEventListener("click", async () => {
const id = (btn as HTMLElement).dataset.toggle;
const todo = todos.find((t) => t.id === id);
if (todo) {
todo.completed = !todo.completed;
await saveTodos(todos);
renderList(container);
}
});
});
container.querySelectorAll("[data-delete]").forEach((btn) => {
btn.addEventListener("click", async () => {
const id = (btn as HTMLElement).dataset.delete;
todos = todos.filter((t) => t.id !== id);
await saveTodos(todos);
ghosty.notifications.toast("Task deleted", { variant: "info" });
renderList(container);
});
});
}
function renderTodoItem(todo: Todo, theme: { colors: Record<string, string> }): string {
const escapedTitle = escapeHtml(todo.title);
const escapedCategory = escapeHtml(todo.category);
return `
<div style="
display: flex; align-items: center; gap: 12px;
padding: 12px; margin-bottom: 8px;
background: ${theme.colors.card}; border-radius: 8px;
border: 1px solid ${theme.colors.border};
${todo.completed ? 'opacity: 0.5;' : ''}
">
<button data-toggle="${todo.id}" style="
width: 20px; height: 20px; border-radius: 50%;
border: 2px solid ${todo.completed ? theme.colors.primary : theme.colors.border};
background: ${todo.completed ? theme.colors.primary : 'transparent'};
cursor: pointer; flex-shrink: 0;
"></button>
<div style="flex: 1; min-width: 0;">
<div style="font-size: 14px; ${todo.completed ? 'text-decoration: line-through;' : ''}">
${escapedTitle}
</div>
<div style="font-size: 11px; opacity: 0.5; margin-top: 2px;">
${escapedCategory}
</div>
</div>
<button data-delete="${todo.id}" style="
background: none; border: none; cursor: pointer;
font-size: 18px; opacity: 0.4; color: ${theme.colors.foreground};
">×</button>
</div>
`;
}
function showAddDialog(container: HTMLElement): void {
const title = prompt("Task title:");
if (!title?.trim()) return;
const category = prompt("Category (optional):") ?? "General";
const todo = createTodo(title.trim(), category.trim() || "General");
todos.unshift(todo);
saveTodos(todos);
ghosty.notifications.toast("Task added!", { variant: "success" });
renderList(container);
}
function escapeHtml(str: string): string {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}These examples use innerHTML for simplicity. In production plugins, consider
using a lightweight framework like Preact or Lit for better DOM diffing,
event delegation, and component composition.
Common patterns
Loading state with skeleton
function renderSkeleton(container: HTMLElement): void {
container.innerHTML = `
<div class="skeleton">
<div class="skeleton-line" style="width: 60%;"></div>
<div class="skeleton-line" style="width: 80%;"></div>
<div class="skeleton-line" style="width: 40%;"></div>
</div>
`;
}Debounced auto-save
let saveTimeout: number | null = null;
function debouncedSave(data: unknown): void {
if (saveTimeout !== null) {
clearTimeout(saveTimeout);
}
saveTimeout = window.setTimeout(async () => {
await ghosty.storage.set("draft", data);
saveTimeout = null;
}, 1000);
}Responsive widget rendering
import { ghosty } from "@ghosty/sdk";
export function render(container: HTMLElement): void {
const size = ghosty.widget.getSize();
renderForSize(container, size.preset);
ghosty.widget.onResize((newSize) => {
renderForSize(container, newSize.preset);
});
}
function renderForSize(
container: HTMLElement,
preset: "small" | "medium" | "large"
): void {
switch (preset) {
case "small":
container.innerHTML = `<div class="compact">...</div>`;
break;
case "medium":
container.innerHTML = `<div class="medium">...</div>`;
break;
case "large":
container.innerHTML = `<div class="full">...</div>`;
break;
}
}Error boundary
export async function render(container: HTMLElement): Promise<void> {
try {
const data = await fetchData();
renderContent(container, data);
} catch (error) {
const message = error instanceof Error
? error.message
: "Something went wrong.";
container.innerHTML = `
<div class="error-state">
<p>${escapeHtml(message)}</p>
<button id="retry-btn">Retry</button>
</div>
`;
container.querySelector("#retry-btn")?.addEventListener("click", () => {
render(container);
});
}
}