Develop a Custom Plugin
Build in your user-owned plugin directory, edit a working Omarchy example, and validate the finished folder with the same manifest rules enforced by the Quattro shell.
They run unsandboxed with your user permissions. Review every dependency and command, avoid unnecessary privileges, and never start a second Quickshell process for a plugin.
Clone a Built-in Plugin
This tutorial builds a bar-widget with a details panel, so the built-in clock is the closest working starting point.
- Match the runtime contractChoose a built-in with the same plugin kind and interaction pattern as the plugin you want to build.
- Work in a user-owned copyEdit the clone under your config directory, never the packaged Omarchy source.
- Expect an immediate switchThe clone command discovers and enables the copy, then replaces the built-in clock in your active bar.
omarchy plugin clone omarchy.clock --editOn success, the command prints the new plugin ID, creates its folder, and opens that folder in your configured editor:
~/.config/omarchy/plugins/yourname.clock/
├── manifest.json
├── BarWidget.qml
├── Panel.qml
└── Model.jsUse the exact ID printed by the command, such as yourname.clock, in every development example below. Saved changes reload automatically. Force discovery only when needed:
omarchy-shell shell rescanPluginsChoose the permanent namespaced ID before publishing.
Browse the built-in plugin examples ↗ before designing a new component from scratch.
Define the Plugin Contract
The clone keeps the clock’s bar-widget contract. Use this reference when another plugin needs a different shell entry point.
| Plugin kind | entryPoints key | File loaded | Use it for |
|---|---|---|---|
bar-widget | barWidget | BarWidget.qml | Item in the active bar |
panel | panel | Panel.qml | Floating surface |
overlay | overlay | Overlay.qml | Fullscreen surface |
menu | menu | Menu.qml | Summoned menu |
service | service | Service.qml | Headless singleton |
bar | bar | Bar.qml | Full bar replacement |
For this tutorial, keep bar-widget, open manifest.json, and replace its contents with this complete development manifest:
JSON manifest.json
{
"schemaVersion": 1,
"id": "yourname.clock",
"name": "Custom Clock",
"version": "1.0.0",
"author": "Your name",
"license": "MIT",
"description": "A small clock with a details panel for the Omarchy bar.",
"kinds": ["bar-widget"],
"entryPoints": { "barWidget": "BarWidget.qml" },
"barWidget": {
"displayName": "Custom Clock",
"category": "Time",
"allowMultiple": false,
"defaultSection": "center"
},
"omarchy": { "clonedFrom": "omarchy.clock" }
}Keep kinds: ["bar-widget"] and entryPoints.barWidget: "BarWidget.qml". The entry point loads Panel.qml internally, so this example does not declare a separate panel kind. Keep the clone-generated omarchy.clonedFrom value while developing so disabling or removing the clone restores the built-in clock.
Implement the Bar and Panel
BarWidget.qml is the manifest entry point. It displays the clock, loads Panel.qml, and forwards the panel lifecycle that Quattro uses for clicks and shell commands.
QML BarWidget.qml
import QtQuick
import Quickshell
import qs.Ui
BarWidget {
id: root
moduleName: "yourname.clock"
readonly property bool opened: panelLoader.item
? panelLoader.item.opened === true
: false
readonly property bool popoutSwitchClosing: panelLoader.item
? panelLoader.item.popoutSwitchClosing === true
: false
function open() {
if (panelLoader.item) panelLoader.item.open()
}
function close() {
if (panelLoader.item) panelLoader.item.close()
}
function toggle() {
if (panelLoader.item) panelLoader.item.toggle()
}
function closeForPopoutSwitch() {
if (panelLoader.item) panelLoader.item.closeForPopoutSwitch()
}
function injectPanel() {
if (!panelLoader.item) return
panelLoader.item.bar = root.bar
panelLoader.item.anchorItem = button
panelLoader.item.hostWidget = root
}
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
onBarChanged: injectPanel()
SystemClock {
id: clock
precision: SystemClock.Minutes
}
Loader {
id: panelLoader
active: true
source: Qt.resolvedUrl("Panel.qml")
visible: false
onLoaded: {
root.injectPanel()
Qt.callLater(root.injectPanel)
}
}
WidgetButton {
id: button
anchors.fill: parent
bar: root.bar
text: Qt.formatTime(clock.date, "HH:mm")
tooltipText: "Open Custom Clock"
onPressed: function(buttonCode) {
if (buttonCode === Qt.LeftButton) root.toggle()
}
}
}
Now replace Panel.qml. Quattro’s Panel base provides the open state and controller; KeyboardPanel anchors the surface to the bar button, and Escape closes it through PanelKeyCatcher.
QML Panel.qml
import QtQuick
import Quickshell
import qs.Commons
import qs.Ui
Panel {
id: root
moduleName: "yourname.clock"
manageIpc: false
property var anchorItem: null
property var hostWidget: null
function open() {
root.controller.show()
}
function close() {
root.controller.hide()
}
function switchPanel(direction) {
if (root.bar && typeof root.bar.switchPanelFrom === "function")
return root.bar.switchPanelFrom(root.hostWidget || root, direction)
return false
}
SystemClock {
id: clock
precision: SystemClock.Minutes
}
KeyboardPanel {
id: panel
anchorItem: root.anchorItem
owner: root.hostWidget || root
bar: root.bar
open: root.opened
focusTarget: keyCatcher
contentWidth: panel.fittedContentWidth(Style.space(240))
contentHeight: panel.fittedContentHeight(content.implicitHeight)
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
onCloseRequested: root.close()
onTabRequested: function(direction) { root.switchPanel(direction) }
Column {
id: content
width: parent.width
spacing: Style.space(8)
Text {
width: parent.width
text: Qt.formatDateTime(clock.date, "dddd, d MMMM yyyy")
color: root.barForeground
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.font.subtitle
font.bold: true
wrapMode: Text.WordWrap
}
Text {
width: parent.width
text: Qt.formatTime(clock.date, "HH:mm")
color: root.barForeground
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.font.displayLarge
}
}
}
}
}
bar-widget plugin.The manifest loads BarWidget.qml; its Loader loads Panel.qml. Keep the same moduleName in both files, and do not add a second manifest kind for this nested panel.
This structure follows Omarchy’s built-in clock plugin and the Marketplace’s Agent Usage plugin. The official Omarchy shell reference ↗ remains the source of truth for the runtime contract.
Validate the Folder
Check both parts of the plugin without running it. Omarchy validates the manifest and repository layout; qmllint checks the bar and panel against the installed shell imports.
PLUGIN_ID="yourname.clock"
PLUGIN_DIR="$HOME/.config/omarchy/plugins/$PLUGIN_ID"
omarchy plugin validate "$PLUGIN_DIR"
qmllint -I "$OMARCHY_PATH/shell" \
"$PLUGIN_DIR/BarWidget.qml" "$PLUGIN_DIR/Panel.qml"Both commands should exit without an error. A broken manifest mapping gives an actionable message, for example:
entry point file not found: 'BarWidget.qml'- Manifest parses as JSON
schemaVersion,id,name,version,kinds, andentryPointsmust be present and valid. - Kind and entry point agreeA
bar-widgetneedsentryPoints.barWidget; a standalonepanelkind needsentryPoints.panel. - Every referenced file existsManifest entry points must be safe relative paths, and every QML file passed to
qmllintmust exist. - No forbidden ID or symlinkThird-party IDs cannot use
omarchy.*, and plugin folders cannot contain symlinks.
Run & Inspect the Plugin
The clone command already discovers, enables, and places the new bar widget. After validation succeeds, confirm that Quattro still lists it as enabled:
PLUGIN_ID="yourname.clock"
omarchy plugin list --json \
| jq --arg id "$PLUGIN_ID" '.[] | select(.id == $id)'For the bar-widget example, the result should include your ID, kind, and enabled state:
{
"id": "yourname.clock",
"kinds": ["bar-widget"],
"enabled": true
}Test the panel through the same bar-widget ID. Quattro routes these commands to the open() and close() methods exposed by BarWidget.qml:
PLUGIN_ID="yourname.clock"
omarchy-shell shell summon "$PLUGIN_ID" '{}'The command should open the clock details panel. Close it with Escape, then confirm the shell close route separately:
PLUGIN_ID="yourname.clock"
omarchy-shell shell hide "$PLUGIN_ID"Before sharing, test click, Escape, shell open and close, disable, re-enable, shell restart, and removal. If a component does not load, continue to Troubleshooting below.
Finished Example
After testing the plugin, replace the temporary clone ID with a permanent namespaced ID and remove the clone-only omarchy.clonedFrom field. Move these files into a public repository, then select a file to inspect or copy it.
custom-clock/Select a file to inspect itmanifest.json
{
"schemaVersion": 1,
"id": "io.github.yourname.custom-clock",
"name": "Custom Clock",
"version": "1.0.0",
"author": "Your name",
"license": "MIT",
"description": "A small clock with a details panel for the Omarchy bar.",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "BarWidget.qml"
},
"barWidget": {
"displayName": "Custom Clock",
"category": "Time",
"allowMultiple": false,
"defaultSection": "center"
}
}BarWidget.qml
import QtQuick
import Quickshell
import qs.Ui
BarWidget {
id: root
moduleName: "io.github.yourname.custom-clock"
readonly property bool opened: panelLoader.item
? panelLoader.item.opened === true
: false
readonly property bool popoutSwitchClosing: panelLoader.item
? panelLoader.item.popoutSwitchClosing === true
: false
function open() {
if (panelLoader.item) panelLoader.item.open()
}
function close() {
if (panelLoader.item) panelLoader.item.close()
}
function toggle() {
if (panelLoader.item) panelLoader.item.toggle()
}
function closeForPopoutSwitch() {
if (panelLoader.item) panelLoader.item.closeForPopoutSwitch()
}
function injectPanel() {
if (!panelLoader.item) return
panelLoader.item.bar = root.bar
panelLoader.item.anchorItem = button
panelLoader.item.hostWidget = root
}
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
onBarChanged: injectPanel()
SystemClock {
id: clock
precision: SystemClock.Minutes
}
Loader {
id: panelLoader
active: true
source: Qt.resolvedUrl("Panel.qml")
visible: false
onLoaded: {
root.injectPanel()
Qt.callLater(root.injectPanel)
}
}
WidgetButton {
id: button
anchors.fill: parent
bar: root.bar
text: Qt.formatTime(clock.date, "HH:mm")
tooltipText: "Open Custom Clock"
onPressed: function(buttonCode) {
if (buttonCode === Qt.LeftButton) root.toggle()
}
}
}
Panel.qml
import QtQuick
import Quickshell
import qs.Commons
import qs.Ui
Panel {
id: root
moduleName: "io.github.yourname.custom-clock"
manageIpc: false
property var anchorItem: null
property var hostWidget: null
function open() {
root.controller.show()
}
function close() {
root.controller.hide()
}
function switchPanel(direction) {
if (root.bar && typeof root.bar.switchPanelFrom === "function")
return root.bar.switchPanelFrom(root.hostWidget || root, direction)
return false
}
SystemClock {
id: clock
precision: SystemClock.Minutes
}
KeyboardPanel {
id: panel
anchorItem: root.anchorItem
owner: root.hostWidget || root
bar: root.bar
open: root.opened
focusTarget: keyCatcher
contentWidth: panel.fittedContentWidth(Style.space(240))
contentHeight: panel.fittedContentHeight(content.implicitHeight)
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
onCloseRequested: root.close()
onTabRequested: function(direction) { root.switchPanel(direction) }
Column {
id: content
width: parent.width
spacing: Style.space(8)
Text {
width: parent.width
text: Qt.formatDateTime(clock.date, "dddd, d MMMM yyyy")
color: root.barForeground
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.font.subtitle
font.bold: true
wrapMode: Text.WordWrap
}
Text {
width: parent.width
text: Qt.formatTime(clock.date, "HH:mm")
color: root.barForeground
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.font.displayLarge
}
}
}
}
}
README.md
# Custom Clock
A small clock with a details panel for the Omarchy Quattro bar.
## Install
```sh
omarchy plugin add https://github.com/yourname/custom-clock.git --enable
```
## Usage
Click the clock to open or close the details panel. Press Escape to close it.
## Configure
```sh
omarchy bar move io.github.yourname.custom-clock --section center
```
## Remove
```sh
omarchy plugin remove io.github.yourname.custom-clock
```LICENSE
MIT License
Copyright (c) David Heinemeier Hansson
Copyright (c) 2026 Your name
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.An optional preview.png can sit beside these files; it is binary, so it is not included in the copy-ready tree.
Do not copy the ID, repository URL, author, or description unchanged. Document every external dependency, setup step, privilege boundary, service, installer, or remote build used by your plugin.
Troubleshooting
- Plugin Folder Not Found
Use the exact ID printed by
omarchy plugin cloneand confirm the folder under~/.config/omarchy/plugins/. - Entry Point File Not Found
Make the value in
entryPointsmatch the filename and capitalization on disk. - The Plugin Validates but Is Not Listed
Run
omarchy-shell shell rescanPlugins, then inspectomarchy plugin list --json. - The Plugin Is Listed but Does Not Appear
Enable it, confirm the declared kind, and inspect
qs log -p "$OMARCHY_PATH/shell" --tail 100for QML errors. - A Panel Opens Once but Not Again
Forward
opened,open(), andclose()from the bar entry point to the loaded panel.