Skip to content

Build a Plugin

Trevin edited this page Sep 28, 2026 · 2 revisions

Build a Plugin

A plugin is a directory with a plugin.toml manifest, same shape as a theme. It ships QML components the shell loads dynamically, or plain scripts it calls at specific moments: a theme change, a project switch, a workspace launch. Nothing about a plugin is compiled or bundled. The shell reads the manifest, links or copies the files, and loads what the manifest declares.

This walks through building one from nothing: a small Settings-pane plugin that shows a live clock. The mechanics are the same for a bigger surface; this one just stays small.

Before you start

Decide whether this should be a plugin at all. If the answer is "every install needs this" or "this changes core state everyone shares," it's base, not a plugin. A plugin is right when the capability is optional, costs something when present (a timer, a poll, a running process), and someone could reasonably not want it.

Read an existing plugin in aphotic-plugins close to what you're building before writing your own from scratch. Agent Graph is a full [ui.dashboard_tab]; OpenRGB Sync is a theme-hook with no UI at all. See Plugin System for the full roster and what each one declares.

1. Lay out the folder

~/aphotic-plugins/clock-pane/
├── plugin.toml
└── qml/
    └── ClockPane.qml

Anywhere under your local aphotic-plugins checkout works during development. aphotic plugin install <name> --link symlinks it into the shell's plugin directory instead of copying, so edits to the checkout show up without a reinstall.

2. Write the manifest

[plugin]
name = "clock-pane"
display_name = "Clock Pane"
description = "A settings-pane clock with a configurable format."
version = "1.0.0"
author = "your name"
category = "productivity"
capabilities = ["ui-surface"]

[ui.settings_pane]
id = "clockPane"
label = "Clock"
icon = "schedule"
component = "qml/ClockPane.qml"
parent = "appearance"

Plugin System has the full field reference: every surface kind, the hook-based capabilities, and the current plugin roster. The manifest format grows over time, so check a recent example in aphotic-plugins if you're reaching for something that page doesn't mention yet. This tutorial only needs ui.settings_pane, which docks a collapsed section into an existing Settings category (parent) instead of becoming a new one. A rail that grows one entry per plugin does not survive fifty of them.

requires_layer/requires_data are optional here. Set requires_layer if the plugin only makes sense with a profile layer enabled (ai, dev, gaming, security). Leave both off for something that's always relevant, like a clock.

3. Implement the component

qml/ClockPane.qml is a plain QML item. There's no special base class for a settings_pane; it's laid out inside whatever Settings gives it:

import QtQuick
import Quickshell

Column {
    spacing: 12

    SystemClock {
        id: clock
        precision: SystemClock.Minutes
    }

    Text {
        text: Qt.formatDateTime(clock.date, "dddd, d MMMM yyyy · HH:mm")
        font.pixelSize: 16
    }
}

Reach for the shell's own token/singleton layer for colors and spacing instead of hardcoding values: import qs.config for Tokens (spacing, radius, fonts), import qs.services for Colours (the live palette) and Settings, and import qs.components for the shared building blocks (StyledText, StyledRect, SettingsRow and the rest). A plugin's settings pane should look like it belongs, not like a themed guest. The Spectrum (visualizer) and Live Wallpapers plugins both ship a small settings pane worth copying the imports from.

4. Validate before installing

aphotic plugin validate ~/aphotic-plugins/clock-pane

This runs entirely against the folder: nothing installed, no QML loaded. It catches the manifest mistakes that would otherwise surface as a blank pane with nothing logged: a missing required field, a component path that doesn't exist or escapes the plugin's own directory, ui-surface declared with no [ui.*] section (or the reverse), a symlink in the tree, an unrecognized capability. A clean run looks like:

[ ok ] clock-pane: valid (0 warning(s))

A warning doesn't block anything; it's still installable, but worth reading. partially hosted on this build means part of what you declared has nowhere to render on this particular Aphotic version.

5. Install and run it

aphotic plugin install clock-pane --link

--link symlinks your checkout in rather than copying, so this is the loop you stay in while developing. Confirm the shell picked it up:

aphotic plugin list --json | jq '.[] | select(.name == "clock-pane")'

Open Settings → Appearance and look for the Clock section. If it's not there, check Troubleshooting below before assuming the component is broken. A surprising number of "my plugin doesn't render" reports turn out to be a Settings pane docked under the wrong parent, or a requires_layer gate that's never satisfied on this machine.

6. Test the full lifecycle before publishing

  1. validate passes clean
  2. install --link, open every surface it declares
  3. disable hides it, enable restores it exactly as it was
  4. A full shell restart survives an enabled install
  5. remove leaves nothing behind: no stray config keys, no dangling QML module symlink under modules/plugins/, no registry entry
  6. If it declares [owns], confirm removal actually reverts those config keys. That's on the plugin, not automatic.

A plugin that only gets tested by installing it once and looking tends to leave one of these broken silently. disable/enable/remove are cheap to run; run all three before calling it done.

7. Publish

  • A README.md with install/usage/remove sections and the real aphotic plugin install <name> command. Not add; that's a different project's CLI verb.
  • A license file.
  • State what the plugin costs at idle if it runs anything continuously: a timer, a poll, a watched file. That's part of the review, not an afterthought.
  • No core file anywhere names your plugin id. If you found yourself needing to edit something outside your own plugin folder to make it work, that usually means the capability you need doesn't exist yet. Open an issue rather than patching around it locally.
  • Open a PR against aphotic-plugins, or point people at your own repo if it's not going into the shared index yet.

Troubleshooting

Symptom Likely cause
validate fails on a field you're sure you set TOML section headers are matched literally ([ui.settings_pane], not [ui.settings-pane]) and the reader is single-section-match; a duplicate section header further down silently shadows the first
Plugin installs and validates clean, but nothing appears Check requires_layer/requires_data. A gate that's never satisfied renders nothing, with no error. Confirm with aphotic plugin list --json's ui.surfaces[].requires_layer
Settings section doesn't show up where expected parent didn't match an existing Settings category id, so it fell back to the Plugins pane. Valid ids: appearance, themeCreator, personalization, bar, launcher, clock, osd, displays, language, workspaceProfiles, ai, network, power, plugins, system, about
Worked before, went blank after a git pull on a --link install aphotic plugin resync rewrites the registry entry from the manifest on disk. A manifest edit under a linked install doesn't take effect until something re-reads it
Dashboard tab or notch tile vanished after a config resync modules/plugins/<name> lives inside the tree install.sh fully replaces on every config deploy. Run aphotic plugin relink-ui-modules
A singleton that's supposed to act on its own (a timer, a background poll) never fires, no error anywhere Quickshell only constructs a QML singleton the first time some other file names it. A pragma Singleton nothing imports never runs, with no error logged. Make sure your plugin's own component actually references it somewhere, not just declares it
Enabled plugin survives a shell restart with stale state Confirm you're reading Settings/FileView-backed state, not a QML property that resets on construction

Reference

  • Plugin System: the full manifest format, every surface kind and capability, and the current plugin roster
  • CLI Reference: every aphotic plugin subcommand
  • Contributing: the module conventions a component should follow to look native rather than bolted on

Clone this wiki locally