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: false

Widget 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;
}
Security note

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 weather

Widget 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}&deg;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 list

Data 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 &middot; 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};
      ">&times;</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;
}
Production patterns

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);
    });
  }
}