Building Omarchy Plugins With an AI Agent

Linux Omarchy Quickshell AI Agent

For months I kept checking on one machine by hand. My daily-driver laptop runs Omarchy, and behind it sits a homelab: a Proxmox host, a handful of containers, a monitoring stack, and a VPS. One of those containers exposes a small HTTP service that answers a JSON status endpoint, returning a state string and a queue depth. Every time I wanted to know whether it was healthy I opened a terminal and ran curl. It was thirty seconds, a few times a day. None of it was hard; it was just repeated, and a repeated trivial task is exactly the kind of thing a computer should absorb.

Omarchy is the opinionated Arch Linux and Hyprland distribution whose shell is written in QML on top of Quickshell. The bar in that shell is made of widgets, and widgets are plugins. The obvious home for that one number was the bar itself: a glyph and a queue depth, always visible, left-click for detail, right-click to re-poll now. I had assumed the distance between "I want a widget" and "I have a widget" was large, because anything involving QML felt like it should be. It was not. Most of the real time went somewhere else entirely, into a handful of traps that have nothing to do with writing QML.

I built it with an agent doing the heavy lifting: Hermes Agent, from Nous Research. I wrote the brief and made the decisions; it read upstream plugin sources, wrote the manifest and the QML, ran the thing, read the logs, and fixed what broke. This is the guide I wanted before I started, plus an honest account of which parts the agent was good at and which parts I had to hold onto myself.

I have a soft spot for small tools that scratch one itch, the same reason I built a terminal utility that checks a sports score instead of opening a website. This widget is that instinct applied to the bar: one number, nothing else. It pays for itself the first time I glance up instead of reaching for a terminal.

The short version

  • It's mostly a manifest — a JSON file declares what the plugin is; the QML is short and only does what the manifest promises.
  • The service owns the state — one QML file polls the endpoint, and the bar widget and the panel both read from it.
  • The widget stays dumb — it renders a number and forwards clicks; all the logic lives in the service.
  • You can test it without a mouse — an IpcHandler turns the widget into something omarchy-shell can call and read back from.
  • Hot reload lies about new entry points — new kinds and new files need omarchy restart shell, and the error can quote a stale line number.
  • Validation is not proof it runs — after an update you still have to execute the plugin and confirm it emits valid output.

What an Omarchy plugin actually is

An Omarchy shell plugin is a directory of files that the shell loads. User plugins live in ~/.config/omarchy/plugins/<id>/, and most of them are git clones. There is no packaging step, no binary blob, and no registry you have to publish to. If the shell can find the directory and read the manifest inside it, the plugin exists.

The whole thing hangs off a single file called manifest.json. It is the contract between you and the shell: it says what the plugin is called, what version it is, who wrote it, what kinds of things it provides, and which QML file implements each of those things. The shell reads the manifest, learns what to load, and that is the entire mechanism. Everything else is just QML being interpreted.

The kinds vocabulary is worth knowing because it tells you what shape a plugin can take. A bar-widget is the thing that sits in the bar. A panel is the popup that opens when you click it. A menu, an overlay, an osd, and a service are the other options. A plugin can declare several kinds at once, which is how one plugin ends up being both a number in the bar and the panel behind that number.

The manifest is the whole contract

Here is the manifest for the widget I built, a plugin I am calling kestrel.pulsestatus:

{
  "schemaVersion": 1,
  "id": "kestrel.pulsestatus",
  "name": "Pulse Status",
  "version": "1.0.0",
  "author": "Kestrel",
  "license": "MIT",
  "description": "One number from a local JSON status endpoint, in the bar. Left click opens the panel, right click re-polls now.",
  "kinds": ["service", "bar-widget", "panel"],
  "keepLoaded": true,
  "entryPoints": {
    "service": "Service.qml",
    "barWidget": "BarWidget.qml",
    "panel": "Panel.qml"
  }
}

The fields map directly onto what the shell needs. schemaVersion tells the shell which shape of manifest it is reading. id is the identity of the plugin everywhere — in the directory name, in the bar, and in every command that moves it around. name, version, author, license, and description are metadata, mostly for humans and for omarchy plugin validate. keepLoaded tells the shell to keep the plugin resident rather than tearing it down whenever nothing is looking at it.

The two fields that matter most are kinds and entryPoints. kinds is the list of things this plugin provides, drawn from the vocabulary I just named. entryPoints maps each kind to the QML file that implements it. I declared three kinds — service, bar-widget, and panel — and pointed each at its own file.

The split is deliberate, and it is the most important design decision in the whole plugin. The service is where state lives: it owns the polling, the endpoint, and the fields that represent "what is the status right now." The bar-widget and the panel are two different views of that same state. Because the poll happens in exactly one place, two views of the data can never disagree about it. The bar shows the queue depth; the panel shows the state string and the last poll time; both read from the same service object. If I had put the poll inside the bar widget, the panel would have had to poll separately, and sooner or later the two would show different numbers.

Writing the widget

The service is the interesting file. It polls one JSON endpoint on a timer and exposes the result as plain properties:

import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons

// The service is where state lives: it polls one JSON status endpoint and exposes
// the fields that the bar widget and the panel both read. Because the poll lives
// in one place, two views of the same data cannot disagree about it.
Item {
  id: root
  property string endpoint: "http://127.0.0.1:8099/status"
  property int pollSeconds: 60

  property real queueDepth: 0
  property string state: "unknown"
  property string lastPolled: "never"
  property bool polling: false

  function poll() {
    if (pollProcess.running) return
    polling = true
    pollProcess.running = true
  }

  Timer {
    interval: root.pollSeconds * 1000
    running: true
    repeat: true
    triggeredOnStart: true
    onTriggered: root.poll()
  }

  Process {
    id: pollProcess
    command: ["curl", "-s", "--max-time", "5", root.endpoint]
    stdout: StdioCollector { id: pollOut; waitForEnd: true }
    onExited: function (exitCode) {
      root.polling = false
      if (exitCode !== 0) {
        root.state = "unreachable"
        return
      }
      try {
        var data = JSON.parse(pollOut.text)
        root.queueDepth = Number(data.queue_depth) || 0
        root.state = String(data.state || "unknown")
        root.lastPolled = new Date().toLocaleTimeString(Qt.locale(), "hh:mm")
      } catch (e) {
        root.state = "bad json"
      }
    }
  }
}

A few things in here carry the whole design. The endpoint is a local JSON URL, and the poll runs on a Timer with a cadence I set in seconds. The Process runs curl with a timeout, captures standard output into a StdioCollector with waitForEnd, and the onExited handler parses whatever came back. If curl fails, the state becomes unreachable. If the JSON does not parse, the state becomes bad json. If it works, the queue depth and state get stored on the service where everything else can read them. There is also a polling guard so two overlapping requests cannot stack up when the endpoint is slow.

The decision to poll a JSON endpoint rather than scrape anything is the reason the whole widget is this short. A machine-readable status endpoint is an interface; scraping rendered output is archaeology. The endpoint on this particular service came out of work I did rebuilding a monitoring dashboard, and having it expose plain JSON meant the widget side never had to guess about formatting. The catch around JSON.parse is there because real endpoints sometimes return HTML error pages, and a widget that takes the whole bar down over a malformed response is worse than a widget that just says "bad json" and tries again next cycle.

The bar widget is deliberately dumb:

import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Ui

// The bar widget is deliberately dumb: it renders what the service holds and
// forwards clicks. All of the logic is in Service.qml.
BarWidget {
  id: root
  moduleName: "kestrel.pulsestatus"

  readonly property var status: bar && bar.shell
    ? bar.shell.serviceFor("kestrel.pulsestatus") : null

  readonly property int depth: status ? status.queueDepth : 0
  readonly property string glyph: "\uf0e7"

  function refresh() { if (status) status.poll() }

  visible: depth > 0
  implicitWidth: label.implicitWidth
  implicitHeight: label.implicitHeight

  Text {
    id: label
    text: root.glyph + " " + root.depth
  }

  MouseArea {
    anchors.fill: parent
    acceptedButtons: Qt.LeftButton | Qt.RightButton
    onClicked: function (mouse) {
      if (mouse.button === Qt.RightButton) root.refresh()
      else if (bar && bar.shell) bar.shell.toggle("kestrel.pulsestatus", "{}")
    }
  }

  IpcHandler {
    target: "kestrel.pulsestatus"

    function refresh(): void { root.refresh() }

    // Scriptable, so a test can read the widget's state without a mouse:
    //   omarchy-shell kestrel.pulsestatus report
    function report(): string {
      if (!root.status) return JSON.stringify({ error: "service not loaded" })
      return JSON.stringify({
        queue_depth: root.status.queueDepth,
        state: root.status.state,
        last_polled: root.status.lastPolled
      })
    }
  }
}

This file does three things and nothing else. It reaches the service with bar.shell.serviceFor("kestrel.pulsestatus"), renders the queue depth next to a glyph, and forwards clicks — left opens the panel, right calls poll() again immediately. All the logic lives in the service. The widget does not parse anything, does not own a timer, and holds no state that matters. It also hides itself when the depth is zero, which keeps a healthy-but-idle service from printing a useless glyph in the bar all day.

The IpcHandler at the bottom is the part I did not know I wanted until I had it. It declares methods on the widget that can be invoked from outside the UI and that return strings. That means the widget is scriptable: a test can ask it for its state without a mouse, and a script can force a refresh. I will come back to why that matters in the next section, because it is the thing that made the whole plugin testable at all.

Running and testing it without touching the mouse

Getting from a directory of files to a number in the bar is a short sequence:

cd ~/.config/omarchy/plugins && git clone <your-repo> kestrel.pulsestatus
omarchy plugin validate kestrel.pulsestatus
omarchy plugin enable kestrel.pulsestatus
omarchy bar put kestrel.pulsestatus --after omarchy.clock
omarchy restart shell
omarchy-shell kestrel.pulsestatus report

The order matters. Clone into the plugin directory under the id the manifest declares, validate it, enable it, place it in the bar, restart the shell, and then ask it what it sees.

The restart is doing real work, and it is the first place the hot-reload behavior will mislead you. Plugin code and the shell's own config hot-reload on save — the log prints "Local plugin changed, reloading" and you can edit QML and watch it change. But a brand new plugin, or a new entry point, is not an edit. New kinds and new files do not load until omarchy restart shell. I will come back to this because it cost me real time.

The omarchy-shell kestrel.pulsestatus report line is the part that changed how I work. It calls the report() method I declared in the IpcHandler and prints back JSON: the queue depth, the state, and the last poll time. That one line turned "is this thing working" from a visual guess into a question I can ask in a terminal and get a parseable answer to. It also means I can drop the check into a script, which is the whole point of making a widget scriptable in the first place.

For the visual side, I take a screenshot and read it. grim /tmp/x.png grabs the screen, and reading the image is faster than squinting at the bar trying to decide whether a glyph rendered or a number is clipped. This matters more than it sounds, because most widget failures are not "the whole bar crashed." They are "the widget is invisible because a property is undefined" or "the number is there but the panel never opens." A screenshot shows you which of those it is in one step.

The traps that cost me the most time

The QML was the easy part. These five things were the hard part, and each one ate more time than writing the plugin did.

New entry points need a restart, not a reload. I knew plugin code hot-reloads on save. What I did not know is that the reload only picks up changes to files the shell is already loading. When I added the panel kind, or renamed an entry point file, the shell kept running the old revision. Worse, the error message quoted a line number from the file I had already fixed — it would say something was broken at a line that no longer contained that thing. That failure taught me the hard way, the same way a keybind that never fired did: the tool is telling you about its cached view of your config, not your config. The fix is always omarchy restart shell, and I now treat "did I change the kinds or the entry points" as a trigger for a restart before I trust anything else.

manifest.__sourceDir is stripped from user plugins. The shell deletes it before injecting the manifest into the plugin, so you cannot rely on it to find the plugin's own directory. A plugin that wants to read a file relative to itself has to resolve its directory from the QML file instead: Qt.resolvedUrl(".").toString().replace(/^file:\/\//, ""), plus decodeURIComponent if the path can contain spaces. This is what several real plugins do, and it is the kind of thing you only learn by reading other people's code.

Validation is not proof it runs. omarchy plugin validate checks that the manifest is well-formed and the files parse. It does not execute anything. Some plugins run a script at runtime — a TypeScript collector executed under bun — rather than shipping a build, so "validates clean" and "emits JSON" are different claims. After an update I always run the collector and confirm it produces output. And here is the red herring I spent an hour on: the collector streams its output as fixed-size {"type":"chunk","data":...} NDJSON lines, so the data fields have to be concatenated before you parse them. A single parse of the raw stream fails with "Extra data", which looks like an error in the data but is actually just the framing. Concatenate first, parse second.

omarchy plugin update only fast-forwards. If a plugin has a local commit or uncommitted edits, the update refuses with "cannot fast-forward ... you have local changes." The correct response is not to force it. Back the local work up as a patch first, then rebase with git rebase @{u} and re-validate. The conflict almost always lands in CHANGELOG.md, because both sides append entries under the same heading, so you keep both. The actual code files usually merge on their own. Forcing an update to paper over a divergence is how you lose work.

Failures show up in the journal, not on the bar. When a plugin fails to load, the bar just quietly omits it. The error is in the user journal: journalctl --user -f prints a line like WARN qml: Plugin widget <id> failed: when a widget dies. The first time a widget vanished I assumed I had misconfigured the bar placement, and I moved the widget around for a while before I thought to look at the log. Now the log is the first place I look, and the screenshot is the second.

What the agent did, and what I kept

The division of labor was cleaner than I expected, and it is the part of this worth spelling out.

Hermes Agent did the mechanical work. It read the source of existing public plugins to learn the idioms — how a real widget imports Quickshell, how it reaches its own service, how it declares an IpcHandler. It wrote the manifest and the QML. It ran the collector, checked the journal, took a screenshot and confirmed the widget actually rendered, and iterated on whatever failed. That loop — make a change, prove it with evidence, read the failure, change again — is exactly the kind of repetitive diagnostic work an agent is faster at than I am.

What I kept was the decision-making. What to build. What the widget must never do — never block the bar, never crash on malformed input, never own state the panel also needs. Whether a given failure is acceptable to ship with. Those are judgment calls, and they are not delegable, because the agent does not know which tradeoffs I care about. This matches how I generally direct models and agents, which I wrote about in a piece on orchestrating models against a kanban board: the brief is mine, the execution is theirs.

A few habits made the difference. I asked the agent to prove a change with evidence — a journal line, a screenshot, a parsed report — rather than accept its claim that something worked. I kept changes small, so that when something broke I could see which change did it. I had it work from a written brief rather than a free-form instruction, because a brief is something you can check the result against. And I routed the heavy work — writing and debugging QML — to a stronger model, while using a fast model for research and for the reporting afterwards. A plugin update that touches many files is still better reviewed as a diff than trusted on a summary; the agent can produce the diff, but I want to read it myself.

The honest summary is that the agent did the typing and the debugging, and I did the deciding. That is the split that actually works.

Sharing it

Publishing a plugin is the least interesting part of the whole thing, which is a compliment to how little ceremony there is. A plugin is just a git repository. You push it, and anyone can install it with omarchy plugin clone <url>. A README, a LICENSE file, and a version bumped in manifest.json are the entire release process. There is no registry, no approval queue, and no binary to sign.

The one thing I will not let myself hand-wave is the difference between updated and tested. omarchy plugin update moving a repo forward does not mean the widget runs. Validation does not mean it runs. Only running it — executing the collector, reading the journal, taking the screenshot, calling report() — means it runs. When I tell someone I updated a plugin, I try to say which of those two things I actually did.

Reading other people's plugins is also the best documentation there is. Real ones like nixfred.blip, konradk/hass, and robzolkos/github taught me more about how a widget should be shaped than any prose did, because they show working answers to the questions you actually hit: how to reach a service, how to declare an IpcHandler, how to keep a widget alive. I read them the way I read code I intend to imitate, looking for the parts that survive contact with a real bar.

Common questions

How do I write my own Omarchy shell plugin?

Create a directory under ~/.config/omarchy/plugins/<id>/, write a manifest.json declaring your kinds and entry points, and write the QML files those entry points name. Then run omarchy plugin validate, omarchy plugin enable, place it with omarchy bar put, and omarchy restart shell.

Why does my Omarchy bar widget not show up after I edit it?

If you added a new kind or a new entry point, hot-reload does not pick it up, so you need omarchy restart shell. If it still does not appear, check journalctl --user -f for a WARN qml: Plugin widget <id> failed: line.

How do I update an Omarchy plugin that has local changes?

Back the local work up as a patch first, then run git rebase @{u} and re-validate. Do not force the update — omarchy plugin update only fast-forwards, and forcing it over a divergence is how you lose work.

Written by Big Kel

Retired IT professional who runs a homelab, extends his desktop on purpose, and writes down what broke. Find more posts on the blog.