<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Robbe Verhelst</title>
    <description>The latest articles on DEV Community by Robbe Verhelst (@robbeverhelst).</description>
    <link>https://dev.to/robbeverhelst</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F838968%2Fcfc1403a-ff1a-46d1-b12f-2bac3ca443d8.jpeg</url>
      <title>DEV Community: Robbe Verhelst</title>
      <link>https://dev.to/robbeverhelst</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/robbeverhelst"/>
    <language>en</language>
    <item>
      <title>Create GitHub Issues from Telegram Messages with Emoji Reactions</title>
      <dc:creator>Robbe Verhelst</dc:creator>
      <pubDate>Thu, 20 Aug 2026 15:03:06 +0000</pubDate>
      <link>https://dev.to/robbeverhelst/create-github-issues-from-telegram-messages-with-emoji-reactions-2ba1</link>
      <guid>https://dev.to/robbeverhelst/create-github-issues-from-telegram-messages-with-emoji-reactions-2ba1</guid>
      <description>&lt;p&gt;The bug report was already written.&lt;/p&gt;

&lt;p&gt;It was just written in the wrong place.&lt;/p&gt;

&lt;p&gt;Someone dropped it in a Telegram group. A few people replied "same here". Maybe someone added a screenshot. Then the conversation moved on, and the useful report disappeared into the scrollback.&lt;/p&gt;

&lt;p&gt;That is the small but annoying gap &lt;a href="https://github.com/robbeverhelst/gitgram" rel="noopener noreferrer"&gt;gitgram&lt;/a&gt; tries to close:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;React to a Telegram message, get a GitHub issue.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No slash command. No copy-paste. No bot reading every message in the group. Just a reaction on the message that already contains the problem.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcz9robw62i3mtngrgkks.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcz9robw62i3mtngrgkks.gif" alt="Gitgram demo: a Telegram message becomes a GitHub issue after a reaction" width="800" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Issue trackers are where work should end up, but chat is where work usually starts.&lt;/p&gt;

&lt;p&gt;That is especially true for small teams, self-hosted projects, hobby communities, open source maintainers, and Telegram-heavy groups. People do not always think in "tickets". They think in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"This button is broken."&lt;/li&gt;
&lt;li&gt;"The deployment failed again."&lt;/li&gt;
&lt;li&gt;"Same issue on my phone."&lt;/li&gt;
&lt;li&gt;"Can someone look at this later?"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those messages are often good enough to become issues. The friction is making someone open GitHub, choose a repo, write a title, quote the context, add labels, and paste the chat link.&lt;/p&gt;

&lt;p&gt;Most of the time, nobody does that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Gitgram Does
&lt;/h2&gt;

&lt;p&gt;Gitgram is a self-hosted Telegram to GitHub issue bot.&lt;/p&gt;

&lt;p&gt;When someone reacts to a configured Telegram message with an emoji, Gitgram creates a GitHub issue from that message.&lt;/p&gt;

&lt;p&gt;The created issue includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the original message text or caption&lt;/li&gt;
&lt;li&gt;the original author when Telegram exposes it&lt;/li&gt;
&lt;li&gt;the Telegram group name&lt;/li&gt;
&lt;li&gt;the original message link when available&lt;/li&gt;
&lt;li&gt;the person who reacted&lt;/li&gt;
&lt;li&gt;optional labels from config&lt;/li&gt;
&lt;li&gt;a hidden marker used to announce when the issue closes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When the GitHub issue is closed, Gitgram can reply back to the original Telegram message so the chat knows the loop is closed.&lt;/p&gt;

&lt;p&gt;The short version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Telegram message + reaction -&amp;gt; GitHub issue
GitHub issue closed -&amp;gt; Telegram reply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why Reactions?
&lt;/h2&gt;

&lt;p&gt;Slash commands are precise, but they are noisy.&lt;/p&gt;

&lt;p&gt;If a group already has a message saying "the invoice export is broken", replying with &lt;code&gt;/issue invoice export broken&lt;/code&gt; is just ceremony. It repeats the useful information and asks humans to behave like a ticketing system.&lt;/p&gt;

&lt;p&gt;A reaction is lighter.&lt;/p&gt;

&lt;p&gt;It lets the group keep its normal chat flow while still giving maintainers a reliable capture mechanism. Someone spots a message worth tracking, reacts to it, and the issue exists.&lt;/p&gt;

&lt;p&gt;Gitgram uses standard Telegram reactions, because Telegram does not allow arbitrary emoji reactions for everyone. For example, custom emoji require Telegram Premium, and some obvious ticket-style emoji are not in the standard reaction set.&lt;/p&gt;

&lt;p&gt;By default:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;trigger reaction: &lt;code&gt;:eyes:&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;acknowledgement reaction: &lt;code&gt;:ok_hand:&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both are configurable, but Gitgram validates them on startup so you do not end up with a bot that runs successfully and then silently never fires.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Weird Telegram Constraint
&lt;/h2&gt;

&lt;p&gt;The interesting part is not creating a GitHub issue. That is straightforward with a GitHub App.&lt;/p&gt;

&lt;p&gt;The interesting part is getting the original Telegram message.&lt;/p&gt;

&lt;p&gt;Telegram's &lt;code&gt;message_reaction&lt;/code&gt; update tells the bot that someone reacted to a message. It includes the chat, the message id, the user, the date, and the reaction.&lt;/p&gt;

&lt;p&gt;It does not include the message text.&lt;/p&gt;

&lt;p&gt;And the Telegram Bot API does not have a "fetch message by id" endpoint.&lt;/p&gt;

&lt;p&gt;That leaves two realistic designs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Store every group message in a database, then look it up later when someone reacts.&lt;/li&gt;
&lt;li&gt;Fetch the message indirectly only when a reaction happens.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Gitgram uses the second approach.&lt;/p&gt;

&lt;p&gt;When a configured reaction arrives, Gitgram forwards the reacted-to message into a private archive channel. Telegram's &lt;code&gt;forwardMessage&lt;/code&gt; response contains the full forwarded &lt;code&gt;Message&lt;/code&gt;, including the text or caption. Gitgram reads that response and uses it to create the GitHub issue.&lt;/p&gt;

&lt;p&gt;That gives Gitgram a useful privacy property:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it does not need to read every group message&lt;/li&gt;
&lt;li&gt;Telegram privacy mode can stay enabled&lt;/li&gt;
&lt;li&gt;it does not store a database of chat history&lt;/li&gt;
&lt;li&gt;it can work on messages from earlier in the visible chat history&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trade-off is that the group must allow forwarding. If "restrict saving content" is enabled, Telegram blocks the forward and Gitgram cannot read the message.&lt;/p&gt;

&lt;p&gt;That is a Telegram limitation, not a Gitgram preference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why There Is No Database
&lt;/h2&gt;

&lt;p&gt;Gitgram stores the issue-to-message link inside the GitHub issue body as an HTML comment.&lt;/p&gt;

&lt;p&gt;It looks conceptually like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- gitgram: chat=-1001234567890; msg=8842 --&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That marker lets Gitgram map a closed GitHub issue back to the original Telegram chat and message.&lt;/p&gt;

&lt;p&gt;This keeps GitHub as the only durable store:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;no database&lt;/li&gt;
&lt;li&gt;no volume&lt;/li&gt;
&lt;li&gt;no migration story&lt;/li&gt;
&lt;li&gt;no backup process&lt;/li&gt;
&lt;li&gt;no state to preserve when redeploying&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The bot does keep a short in-memory deduplication set. That prevents two people reacting at almost the same time from creating duplicate issues. For longer-term duplicate prevention, it can also search GitHub for an existing marker.&lt;/p&gt;

&lt;p&gt;The design is deliberately boring operationally. A Docker container, a config file, a Telegram bot token, and a GitHub App are enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Setup
&lt;/h2&gt;

&lt;p&gt;The moving parts are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a Telegram bot created with BotFather&lt;/li&gt;
&lt;li&gt;a Telegram supergroup&lt;/li&gt;
&lt;li&gt;a private archive channel&lt;/li&gt;
&lt;li&gt;a GitHub App with issue permissions&lt;/li&gt;
&lt;li&gt;a &lt;code&gt;gitgram.yaml&lt;/code&gt; config file&lt;/li&gt;
&lt;li&gt;a public HTTPS endpoint for GitHub webhooks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A minimal Telegram to GitHub issue routing config looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;chats&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;-1001234567890&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;repo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;owner/repo&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;from-telegram&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the self-hosted Telegram GitHub issue bot with Docker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--env-file&lt;/span&gt; .env &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;/gitgram.yaml:/app/gitgram.yaml:ro"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 3000:3000 &lt;span class="se"&gt;\&lt;/span&gt;
  ghcr.io/robbeverhelst/gitgram:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Telegram uses long polling, so it does not need an inbound webhook. GitHub does need to reach &lt;code&gt;POST /gh/webhook&lt;/code&gt; so issue close announcements can flow back into Telegram.&lt;/p&gt;

&lt;p&gt;For local testing, a tunnel such as Cloudflare Tunnel is enough. For production, any stable HTTPS endpoint works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Requirements Worth Knowing
&lt;/h2&gt;

&lt;p&gt;Gitgram is intentionally narrow, but Telegram has a few hard rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the group must be a supergroup&lt;/li&gt;
&lt;li&gt;the bot must be a group administrator&lt;/li&gt;
&lt;li&gt;the trigger emoji must be enabled in the group&lt;/li&gt;
&lt;li&gt;the group must not restrict saving content&lt;/li&gt;
&lt;li&gt;the bot must be an admin in the private archive channel&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The admin requirement is not about power. Telegram simply does not deliver reaction updates to the bot otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Silent Mode
&lt;/h2&gt;

&lt;p&gt;Some groups do not want bot chatter.&lt;/p&gt;

&lt;p&gt;Gitgram can be configured to create issues silently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;chats&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;-1001234567890&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;repo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;owner/repo&lt;/span&gt;
    &lt;span class="na"&gt;announce_created&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;announce_closed&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;ack_reaction&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this setup, the bot still reacts to the original message as acknowledgement, but it does not post routine success messages. Issue links are also private by default: &lt;code&gt;include_link&lt;/code&gt; defaults to &lt;code&gt;false&lt;/code&gt;, so chat announcements do not expose the repository URL unless you opt in.&lt;/p&gt;

&lt;p&gt;Errors still reply in chat. That is deliberate. If someone reacts to create a ticket and something fails, that person needs to know immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Known Limits
&lt;/h2&gt;

&lt;p&gt;Gitgram is small on purpose, so the limits are explicit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;media cannot be attached to GitHub issues because GitHub does not expose a public issue attachment API&lt;/li&gt;
&lt;li&gt;close announcements can be missed if the GitHub webhook fails while the bot is down&lt;/li&gt;
&lt;li&gt;removing the reaction does not close the issue&lt;/li&gt;
&lt;li&gt;per-topic repository routing is not possible because Telegram reaction updates do not expose the original forum topic id&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For me, those trade-offs are acceptable because the core workflow stays simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Turn the chat message into an issue before it disappears.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  When This Is Useful
&lt;/h2&gt;

&lt;p&gt;Gitgram fits best when your real coordination already happens in Telegram, but your durable work tracking lives in GitHub Issues.&lt;/p&gt;

&lt;p&gt;Good examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;open source communities&lt;/li&gt;
&lt;li&gt;small product teams&lt;/li&gt;
&lt;li&gt;homelab and self-hosted projects&lt;/li&gt;
&lt;li&gt;support groups&lt;/li&gt;
&lt;li&gt;internal operations chats&lt;/li&gt;
&lt;li&gt;family or volunteer projects that still need an issue tracker&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is not trying to replace Linear, Jira, or GitHub Projects. It is just a bridge from "someone said the useful thing in chat" to "the useful thing now exists somewhere durable".&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing The Loop
&lt;/h2&gt;

&lt;p&gt;The smallest workflow improvements are often the ones people actually use.&lt;/p&gt;

&lt;p&gt;Gitgram is not a full ChatOps platform. It does not try to turn Telegram into a project management UI. It only does one thing:&lt;/p&gt;

&lt;p&gt;React to a Telegram message and file it as a GitHub issue.&lt;/p&gt;

&lt;p&gt;That turns out to be enough to save a lot of useful context from disappearing.&lt;/p&gt;

&lt;p&gt;Repository: &lt;a href="https://github.com/robbeverhelst/gitgram" rel="noopener noreferrer"&gt;github.com/robbeverhelst/gitgram&lt;/a&gt;&lt;/p&gt;

</description>
      <category>telegram</category>
      <category>github</category>
      <category>typescript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Building UniFi Reactor: state-driven network automation for my Kubernetes homelab</title>
      <dc:creator>Robbe Verhelst</dc:creator>
      <pubDate>Sun, 16 Aug 2026 10:17:42 +0000</pubDate>
      <link>https://dev.to/robbeverhelst/building-unifi-reactor-state-driven-network-automation-for-my-kubernetes-homelab-4k50</link>
      <guid>https://dev.to/robbeverhelst/building-unifi-reactor-state-driven-network-automation-for-my-kubernetes-homelab-4k50</guid>
      <description>&lt;p&gt;A few weeks ago I added a UPS and backup internet to my homelab. That solved one class of problem, but it made another one more obvious.&lt;/p&gt;

&lt;p&gt;My UniFi gear knew when the WAN failed over. It knew when the UPS switched to battery. Kubernetes did not.&lt;/p&gt;

&lt;p&gt;So if the main WAN failed at 3 AM, qBittorrent could keep happily seeding over a metered backup link. If power dropped, the UPS could be counting down its remaining runtime while the cluster continued doing background ML jobs, backups, and other work that absolutely did not need to happen during an outage.&lt;/p&gt;

&lt;p&gt;That felt wrong. The network already had the state. The cluster just needed to react to it.&lt;/p&gt;

&lt;p&gt;That is why I built &lt;a href="https://github.com/robbeverhelst/unifi-reactor" rel="noopener noreferrer"&gt;UniFi Reactor&lt;/a&gt;: a Kubernetes operator that watches UniFi Network state and turns it into declarative cluster automations.&lt;/p&gt;

&lt;p&gt;Docs are here: &lt;a href="https://reactor.robbeverhelst.com/" rel="noopener noreferrer"&gt;reactor.robbeverhelst.com&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The basic idea
&lt;/h2&gt;

&lt;p&gt;UniFi Reactor polls the UniFi Network API, normalizes what it sees into state keys, and reconciles Kubernetes &lt;code&gt;Automation&lt;/code&gt; resources against those keys.&lt;/p&gt;

&lt;p&gt;Examples of state keys:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;wan: primary&lt;/code&gt; or &lt;code&gt;wan: backup&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;internet: ok&lt;/code&gt;, &lt;code&gt;degraded&lt;/code&gt;, or &lt;code&gt;down&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ups: online&lt;/code&gt; or &lt;code&gt;on-battery&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ups.battery: normal&lt;/code&gt;, &lt;code&gt;low&lt;/code&gt;, or &lt;code&gt;critical&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;devices: all-online&lt;/code&gt; or &lt;code&gt;degraded&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;device.&amp;lt;name&amp;gt;: online&lt;/code&gt; or &lt;code&gt;offline&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then you write rules like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;reactor.robbeverhelst.com/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Automation&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shed-ml-on-battery&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;reactor-system&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unifi&lt;/span&gt;
    &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;ups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;on-battery&lt;/span&gt;
  &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubernetes.scale&lt;/span&gt;
      &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;immich-machine-learning&lt;/span&gt;
        &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;immich&lt;/span&gt;
      &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
  &lt;span class="na"&gt;onExit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubernetes.scale&lt;/span&gt;
      &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;immich-machine-learning&lt;/span&gt;
        &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;immich&lt;/span&gt;
      &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the UPS goes on battery, Reactor scales down Immich machine learning. When mains power returns, it scales it back up.&lt;/p&gt;

&lt;p&gt;That was the first real automation I deployed because it is boring in exactly the right way. Photo indexing is useful, but nobody cares if it pauses during a power cut.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just use alerts?
&lt;/h2&gt;

&lt;p&gt;I already have Prometheus, Grafana, Gatus, and the usual homelab observability stack. They are good at telling me something happened.&lt;/p&gt;

&lt;p&gt;But an alert that says "the UPS is on battery" is only half useful. The question I actually care about is: what should the system do while that condition is true?&lt;/p&gt;

&lt;p&gt;That difference shaped the project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Monitoring tells you about state.&lt;/li&gt;
&lt;li&gt;Reactor changes cluster behavior while that state holds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For simple notification-only cases, Prometheus is probably still the better tool. Reactor becomes interesting when the response is operational: pause downloads, suspend CronJobs, scale down optional workloads, disable guest WiFi, or shed PoE load.&lt;/p&gt;

&lt;h2&gt;
  
  
  State, not one-shot events
&lt;/h2&gt;

&lt;p&gt;The first design choice was to make Reactor state-driven instead of event-driven.&lt;/p&gt;

&lt;p&gt;That means polling is the source of truth. Webhooks can be a fast path later, but they should not be the mechanism of record.&lt;/p&gt;

&lt;p&gt;Why? Because one-shot events are easy to miss. The controller can restart. The network can flap. A webhook can fail. If the system only reacts to edges, it can get stranded in the wrong mode.&lt;/p&gt;

&lt;p&gt;With state reconciliation, the next observation corrects things.&lt;/p&gt;

&lt;p&gt;The mental model is closer to Kubernetes itself: observe reality, compare it to desired state, reconcile.&lt;/p&gt;

&lt;p&gt;A simplified flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UniFi Network API
      |
      v
UniFi provider observes hardware state
      |
      v
Normalized keys: wan, ups, internet, devices, ...
      |
      v
Reactor matches Automation resources
      |
      v
Actions are applied while conditions hold
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The WAN failover use case
&lt;/h2&gt;

&lt;p&gt;The flagship use case is metered backup internet.&lt;/p&gt;

&lt;p&gt;I run a UniFi setup with a primary WAN and backup connectivity. Once failover is verified end-to-end, I want this kind of automation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;reactor.robbeverhelst.com/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Automation&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pause-downloads-on-backup-wan&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;media&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unifi&lt;/span&gt;
    &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;wan&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;backup&lt;/span&gt;
  &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubernetes.scale&lt;/span&gt;
      &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;qbittorrent&lt;/span&gt;
        &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;servarr&lt;/span&gt;
      &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
  &lt;span class="na"&gt;onExit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubernetes.scale&lt;/span&gt;
      &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;qbittorrent&lt;/span&gt;
        &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;servarr&lt;/span&gt;
      &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the network is on backup WAN, downloads stop. When primary WAN returns, they resume.&lt;/p&gt;

&lt;p&gt;That is a tiny YAML file, but it encodes a very practical policy: do not burn backup data on background traffic.&lt;/p&gt;

&lt;p&gt;Other examples I want to add around this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;suspend offsite backup CronJobs while on metered WAN&lt;/li&gt;
&lt;li&gt;scale down Jellyfin remote streaming&lt;/li&gt;
&lt;li&gt;disable guest WiFi during failover&lt;/li&gt;
&lt;li&gt;notify when the primary link is up but the internet is still down&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some of those are not implemented yet, but the model is clear.&lt;/p&gt;

&lt;h2&gt;
  
  
  The UPS use case
&lt;/h2&gt;

&lt;p&gt;Power loss is the other obvious axis.&lt;/p&gt;

&lt;p&gt;A UPS buys time. The cluster should spend that time intelligently.&lt;/p&gt;

&lt;p&gt;The first deployed reaction was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ups: on-battery&lt;/code&gt; -&amp;gt; scale &lt;code&gt;immich-machine-learning&lt;/code&gt; from 1 to 0&lt;/li&gt;
&lt;li&gt;mains restored -&amp;gt; scale it back to 1&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Future power-loss automations can become more aggressive as the battery drains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ups: on-battery&lt;/code&gt; -&amp;gt; suspend non-critical CronJobs&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ups.battery: low&lt;/code&gt; -&amp;gt; scale down heavier optional workloads&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ups.battery: critical&lt;/code&gt; -&amp;gt; shut down or isolate more aggressively&lt;/li&gt;
&lt;li&gt;power restored -&amp;gt; notify with outage duration&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One important detail: &lt;code&gt;ups&lt;/code&gt; and &lt;code&gt;ups.battery&lt;/code&gt; are separate keys.&lt;/p&gt;

&lt;p&gt;That is deliberate. If a single enum went from &lt;code&gt;online&lt;/code&gt; to &lt;code&gt;on-battery&lt;/code&gt; to &lt;code&gt;battery-low&lt;/code&gt; to &lt;code&gt;critical&lt;/code&gt;, an automation matching &lt;code&gt;on-battery&lt;/code&gt; would stop matching when the battery became low. That could accidentally fire &lt;code&gt;onExit&lt;/code&gt; and scale workloads back up during the outage.&lt;/p&gt;

&lt;p&gt;Separate keys avoid that. An automation can match &lt;code&gt;ups: on-battery&lt;/code&gt; for the whole outage, while another automation can additionally match &lt;code&gt;ups.battery: critical&lt;/code&gt; for escalation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reversal must be explicit
&lt;/h2&gt;

&lt;p&gt;A surprisingly important part of the design is &lt;code&gt;onExit&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Reactor does not guess how to undo something. The automation says what should happen when the condition starts holding, and what should happen when it stops.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubernetes.scale&lt;/span&gt;
    &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;qbittorrent&lt;/span&gt;
      &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;servarr&lt;/span&gt;
    &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;

&lt;span class="na"&gt;onExit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubernetes.scale&lt;/span&gt;
    &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;qbittorrent&lt;/span&gt;
      &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;servarr&lt;/span&gt;
    &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is more verbose than magic, but it is safer. Infrastructure automation gets scary when the tool assumes what "back to normal" means.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens if two automations target the same workload?
&lt;/h2&gt;

&lt;p&gt;This was the part that made the project more than a wrapper around a few scripts.&lt;/p&gt;

&lt;p&gt;Imagine qBittorrent should pause when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the WAN is on backup internet&lt;/li&gt;
&lt;li&gt;the UPS is on battery&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are independent reasons. Either one should keep qBittorrent down.&lt;/p&gt;

&lt;p&gt;If one automation exits while the other still matches, Reactor must not scale the deployment back up.&lt;/p&gt;

&lt;p&gt;So Reactor arbitrates shared targets. Desired-state actions like &lt;code&gt;kubernetes.scale&lt;/code&gt; are treated as levels. The most restrictive active level wins. A workload comes back only when no matching automation still wants it down.&lt;/p&gt;

&lt;p&gt;That avoids the classic failure mode where two scripts fight each other:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Power fails, script A scales workload down.&lt;/li&gt;
&lt;li&gt;WAN recovers, script B scales workload up.&lt;/li&gt;
&lt;li&gt;Power is still out. Congratulations, your automation is now actively dumb.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Reactor tracks the active claims instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Safety defaults
&lt;/h2&gt;

&lt;p&gt;I wanted this to be safe enough to run in my actual cluster, not just impressive in a README.&lt;/p&gt;

&lt;p&gt;The operator uses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a dedicated ServiceAccount&lt;/li&gt;
&lt;li&gt;no &lt;code&gt;cluster-admin&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Kubernetes Secrets for credentials&lt;/li&gt;
&lt;li&gt;explicit RBAC for supported actions&lt;/li&gt;
&lt;li&gt;dry-run support&lt;/li&gt;
&lt;li&gt;allowlists for actions that leave the cluster&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example, actions like HTTP requests, UniFi WLAN changes, PoE cycling, and UPS outlet control are refused unless the destination or target is explicitly allowed.&lt;/p&gt;

&lt;p&gt;That matters because a Kubernetes operator with access to your network controller is a sharp tool. It should not become "curl with cluster permissions".&lt;/p&gt;

&lt;h2&gt;
  
  
  Current action types
&lt;/h2&gt;

&lt;p&gt;The project already supports more than just scaling Deployments.&lt;/p&gt;

&lt;p&gt;Action families include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;kubernetes.scale&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;kubernetes.cronjob.suspend&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;kubernetes.cordon&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;kubernetes.restart&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;http.request&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;notification.ntfy&lt;/code&gt;, &lt;code&gt;notification.discord&lt;/code&gt;, &lt;code&gt;notification.slack&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;homeassistant.service&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;qbittorrent.pause&lt;/code&gt; and &lt;code&gt;qbittorrent.resume&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;unifi.wlan.enable&lt;/code&gt; and &lt;code&gt;unifi.wlan.disable&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;unifi.poe.cycle&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;unifi.outlet.cut&lt;/code&gt; and &lt;code&gt;unifi.outlet.restore&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is intentionally no arbitrary shell action. That would be convenient, and also how you turn a small operator into a haunted CI runner.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned from building it
&lt;/h2&gt;

&lt;p&gt;A few lessons stood out.&lt;/p&gt;

&lt;p&gt;First, UniFi exposes useful operational state, but it is not always shaped the way you would design a clean public API. Normalizing it into a small vocabulary is worth the effort.&lt;/p&gt;

&lt;p&gt;Second, "event-driven" sounds elegant until you care about recovery. For infrastructure state, reconciliation beats clever edge handling.&lt;/p&gt;

&lt;p&gt;Third, undo behavior deserves first-class design. It is easy to write automation that does something. It is harder to make it stop doing that thing at the right time.&lt;/p&gt;

&lt;p&gt;Fourth, homelab projects are best when they are boring under pressure. A power cut is not the moment to discover your automation has opinions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is next
&lt;/h2&gt;

&lt;p&gt;The project is public and released as a multi-arch container image and Helm chart.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/robbeverhelst/unifi-reactor" rel="noopener noreferrer"&gt;github.com/robbeverhelst/unifi-reactor&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://reactor.robbeverhelst.com/" rel="noopener noreferrer"&gt;reactor.robbeverhelst.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Latest release at the time of writing: &lt;code&gt;v1.2.0&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The next things I want to harden are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;real WAN failover captures once my backup SIM setup is fully tested&lt;/li&gt;
&lt;li&gt;more production use of CronJob suspension during outages&lt;/li&gt;
&lt;li&gt;better hardware-health reactions for UniFi devices&lt;/li&gt;
&lt;li&gt;maybe more providers later, like NUT, Prometheus, or Proxmox&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The broader idea is simple: your infrastructure already knows a lot about the conditions it is running under. UniFi Reactor is my attempt to let Kubernetes respond to that context without turning the homelab into a pile of one-off scripts.&lt;/p&gt;

&lt;p&gt;That is the kind of boring automation I want more of.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>homelab</category>
      <category>unifi</category>
      <category>devops</category>
    </item>
    <item>
      <title>Resend Prometheus Monitoring: Alert on Email Bounces in Grafana</title>
      <dc:creator>Robbe Verhelst</dc:creator>
      <pubDate>Wed, 22 Jul 2026 14:06:51 +0000</pubDate>
      <link>https://dev.to/robbeverhelst/resend-prometheus-monitoring-alert-on-email-bounces-in-grafana-3h4i</link>
      <guid>https://dev.to/robbeverhelst/resend-prometheus-monitoring-alert-on-email-bounces-in-grafana-3h4i</guid>
      <description>&lt;p&gt;If you are looking for Resend Prometheus monitoring, Resend Grafana alerts, or a way to alert on email bounces, that is exactly what &lt;a href="https://github.com/robbeverhelst/resend-exporter" rel="noopener noreferrer"&gt;&lt;code&gt;resend-exporter&lt;/code&gt;&lt;/a&gt; is for.&lt;/p&gt;

&lt;p&gt;It is a small Prometheus exporter and webhook receiver for Resend email events. It receives Resend webhooks, verifies their Svix signatures, exposes clean Prometheus metrics, and gives Grafana something useful to alert on when transactional email delivery breaks.&lt;/p&gt;

&lt;p&gt;The reason I built it was simple.&lt;/p&gt;

&lt;p&gt;An appointment confirmation email bounced.&lt;/p&gt;

&lt;p&gt;The appointment itself existed. The application did its job. Resend knew the email had failed. But unless someone checked the right dashboard or the customer asked about it, that failure could quietly sit there as a support problem instead of an operational signal.&lt;/p&gt;

&lt;p&gt;That felt wrong.&lt;/p&gt;

&lt;p&gt;Transactional email is part of the product. Password resets, appointment confirmations, invoices, login links, onboarding flows, receipts: when they fail, the user does not care that your API returned &lt;code&gt;200 OK&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It turns this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Resend knows an email bounced somewhere in a vendor dashboard.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Into this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Prometheus sees &lt;code&gt;resend_email_events_total{event_type="email.bounced"}&lt;/code&gt; increase and Grafana can alert me.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The problem with silent Resend email failure
&lt;/h2&gt;

&lt;p&gt;Most web apps treat email as a side effect.&lt;/p&gt;

&lt;p&gt;The request succeeds, the database is updated, and some email provider gets asked to deliver a message. At that point the application often considers the work done.&lt;/p&gt;

&lt;p&gt;But delivery is where the user value actually happens.&lt;/p&gt;

&lt;p&gt;If an appointment confirmation bounces, the appointment is technically created but operationally degraded. If a password reset fails, authentication is broken for that user. If an invoice email never arrives, someone eventually has to untangle the mess.&lt;/p&gt;

&lt;p&gt;Email providers like Resend already emit useful delivery events:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;email.sent&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;email.delivered&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;email.delivery_delayed&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;email.bounced&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;email.failed&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;email.complained&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The missing piece, for me, was getting those events into the monitoring stack I already use.&lt;/p&gt;

&lt;p&gt;I did not want another dashboard to remember.&lt;/p&gt;

&lt;p&gt;I wanted Prometheus metrics, Grafana dashboards, and alert rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Resend Prometheus exporter does
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;resend-exporter&lt;/code&gt; is deliberately small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Resend
  -&amp;gt; Svix-signed webhook
  -&amp;gt; resend-exporter
       - verify
       - count
       - log
  -&amp;gt; /metrics for Prometheus
  -&amp;gt; Grafana dashboards and alerts
  -&amp;gt; JSON logs for Loki or stdout
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It exposes four HTTP endpoints:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;POST&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/webhooks/resend&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Receive Resend webhook events&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/metrics&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Prometheus metrics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/healthz&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Liveness probe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/readyz&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Readiness probe&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The webhook handler verifies Resend's Svix signature, validates the payload, updates Prometheus counters and gauges, and emits one structured JSON log line per accepted event.&lt;/p&gt;

&lt;p&gt;No database. No queue. No Resend API key required for the basic webhook mode.&lt;/p&gt;

&lt;p&gt;Just receive, verify, count, log.&lt;/p&gt;

&lt;h2&gt;
  
  
  Metrics are not logs
&lt;/h2&gt;

&lt;p&gt;The most important design choice was separating metrics from details.&lt;/p&gt;

&lt;p&gt;Prometheus should answer questions like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;how many emails bounced?&lt;/li&gt;
&lt;li&gt;did failures increase in the last five minutes?&lt;/li&gt;
&lt;li&gt;which event types are happening?&lt;/li&gt;
&lt;li&gt;are failures concentrated by sending domain or broad recipient domain?&lt;/li&gt;
&lt;li&gt;has the webhook gone stale?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It should not store every recipient address, subject line, or Resend email ID as labels.&lt;/p&gt;

&lt;p&gt;That way lies cardinality pain.&lt;/p&gt;

&lt;p&gt;So the exporter exposes metrics like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;resend_webhook_events_total{event_type="email.bounced",domain="acme.dev"}
resend_email_events_total{event_type="email.failed",from_domain="acme.dev",to_domain="outlook.com"}
resend_webhook_signature_failures_total
resend_webhook_handler_errors_total{reason="invalid_json"}
resend_webhook_last_event_timestamp_seconds{event_type="email.delivered"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those labels are intentionally boring.&lt;/p&gt;

&lt;p&gt;The more sensitive forensic detail goes into structured logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"level"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"warn"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email.bounced"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resend_email_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3ebe19b6-1dcc-4534-8442-9dc689ee439b"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"from_domain"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"to_domain"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"outlook.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"recipient_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Recipient mail server not found"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event_created_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-21T00:24:00Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Metrics tell you that something is wrong.&lt;/p&gt;

&lt;p&gt;Logs tell you what broke.&lt;/p&gt;

&lt;p&gt;Keeping those jobs separate makes the exporter much safer to operate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping Prometheus labels under control
&lt;/h2&gt;

&lt;p&gt;Email data is cardinality bait.&lt;/p&gt;

&lt;p&gt;It is very tempting to add labels like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;to="customer@example.com"
subject="Your appointment confirmation"
email_id="..."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That would make the first dashboard look convenient and the second month of Prometheus look stupid.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;resend-exporter&lt;/code&gt; avoids high-cardinality labels by design:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;full recipient emails are never metric labels&lt;/li&gt;
&lt;li&gt;subjects are never metric labels&lt;/li&gt;
&lt;li&gt;Resend email IDs are never metric labels&lt;/li&gt;
&lt;li&gt;recipient domains are bucketed&lt;/li&gt;
&lt;li&gt;the long tail can collapse into &lt;code&gt;other&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;extra recipient domains can be allowlisted when needed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For private deployments, the logs can include more detail. For shared or stricter setups, redaction can stay strict or hash sensitive fields.&lt;/p&gt;

&lt;p&gt;The current redaction modes are:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;strict&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Do not log recipient email or subject&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Log stable SHA-256 hashes for correlation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;none&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Log raw values for private deployments&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The default is strict.&lt;/p&gt;

&lt;p&gt;Because "just put it in a label" is how dashboards become landmines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resend alerting examples for Prometheus and Grafana
&lt;/h2&gt;

&lt;p&gt;Once the events are in Prometheus, the alert rules become ordinary PromQL.&lt;/p&gt;

&lt;p&gt;Any failed email:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;increase(resend_email_events_total{event_type="email.failed"}[5m]) &amp;gt; 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any bounced transactional email:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;increase(resend_email_events_total{event_type="email.bounced"}[5m]) &amp;gt; 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repeated delivery delays:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;increase(resend_email_events_total{event_type="email.delivery_delayed"}[30m]) &amp;gt;= 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Signature failures:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;increase(resend_webhook_signature_failures_total[5m]) &amp;gt; 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last one is useful because webhook endpoints are public by necessity. Bad signatures should be rejected, counted, and visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running resend-exporter
&lt;/h2&gt;

&lt;p&gt;With Docker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:8080 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;RESEND_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;whsec_... &lt;span class="se"&gt;\&lt;/span&gt;
  ghcr.io/robbeverhelst/resend-exporter:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With Helm:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm &lt;span class="nb"&gt;install &lt;/span&gt;resend-exporter oci://ghcr.io/robbeverhelst/charts/resend-exporter &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; resend.webhookSecret&lt;span class="o"&gt;=&lt;/span&gt;whsec_... &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; serviceMonitor.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For local testing, the repo includes a compose playground with the exporter, Prometheus alert rules, and a provisioned Grafana dashboard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then configure a Resend webhook to point at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://your-host/webhooks/resend
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The webhook path must be internet-reachable.&lt;/p&gt;

&lt;p&gt;The metrics endpoint should not be.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ships today
&lt;/h2&gt;

&lt;p&gt;The repository currently includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bun/TypeScript implementation&lt;/li&gt;
&lt;li&gt;Svix webhook signature verification&lt;/li&gt;
&lt;li&gt;Prometheus metrics via &lt;code&gt;prom-client&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;structured JSON logging&lt;/li&gt;
&lt;li&gt;strict/hash/none redaction modes&lt;/li&gt;
&lt;li&gt;recipient-domain bucketing&lt;/li&gt;
&lt;li&gt;Docker image&lt;/li&gt;
&lt;li&gt;Helm chart&lt;/li&gt;
&lt;li&gt;docker-compose playground&lt;/li&gt;
&lt;li&gt;Grafana dashboard&lt;/li&gt;
&lt;li&gt;Prometheus alert examples&lt;/li&gt;
&lt;li&gt;CI and automated releases&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/robbeverhelst/resend-exporter" rel="noopener noreferrer"&gt;github.com/robbeverhelst/resend-exporter&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The current release is &lt;a href="https://github.com/robbeverhelst/resend-exporter/releases/tag/v0.2.1" rel="noopener noreferrer"&gt;&lt;code&gt;v0.2.1&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I still want to add
&lt;/h2&gt;

&lt;p&gt;The next useful feature is optional Resend API reconciliation.&lt;/p&gt;

&lt;p&gt;Webhook-only mode is intentionally simple, but an API key could enable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;backfilling recent events after exporter downtime&lt;/li&gt;
&lt;li&gt;checking whether required Resend webhook events are configured&lt;/li&gt;
&lt;li&gt;enriching sparse webhook events&lt;/li&gt;
&lt;li&gt;exposing configuration health metrics&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also want to add delivery-delay histograms, bounce-type breakdowns, and first-class &lt;code&gt;PrometheusRule&lt;/code&gt; support in the Helm chart.&lt;/p&gt;

&lt;h2&gt;
  
  
  The larger point
&lt;/h2&gt;

&lt;p&gt;This project is small, but the pattern matters.&lt;/p&gt;

&lt;p&gt;If a user-facing side effect can fail after your application says "success", it deserves observability.&lt;/p&gt;

&lt;p&gt;Transactional email is not background noise. It is production behavior.&lt;/p&gt;

&lt;p&gt;And if production behavior breaks, I want the same boring machinery that watches everything else to notice.&lt;/p&gt;

&lt;p&gt;That is all &lt;code&gt;resend-exporter&lt;/code&gt; really does.&lt;/p&gt;

&lt;p&gt;It makes email delivery failures page-shaped.&lt;/p&gt;

</description>
      <category>prometheus</category>
      <category>resend</category>
      <category>monitoring</category>
      <category>grafana</category>
    </item>
    <item>
      <title>AI-Powered Media Server Management with OpenClaw and Tsarr</title>
      <dc:creator>Robbe Verhelst</dc:creator>
      <pubDate>Mon, 30 Mar 2026 08:39:30 +0000</pubDate>
      <link>https://dev.to/robbeverhelst/ai-powered-media-server-management-with-openclaw-and-tsarr-494h</link>
      <guid>https://dev.to/robbeverhelst/ai-powered-media-server-management-with-openclaw-and-tsarr-494h</guid>
      <description>&lt;p&gt;Last weekend I wanted to know which TV series were eating the most storage on my media server. Simple question.&lt;/p&gt;

&lt;p&gt;My AI assistant (running on &lt;a href="https://openclaw.ai" rel="noopener noreferrer"&gt;OpenClaw&lt;/a&gt;) tried to answer it. It took multiple approaches, five attempts, and several minutes of fumbling before it found the data. Top Gear at 494 GB. Pokémon at 362 GB. South Park at 344 GB. The answer was there, but getting to it was painful.&lt;/p&gt;

&lt;p&gt;The thing is, I'd already built a tool that does this in one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tsarr sonarr series list &lt;span class="nt"&gt;--json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/robbeverhelst/Tsarr" rel="noopener noreferrer"&gt;Tsarr&lt;/a&gt; is a type-safe CLI I built for managing Radarr, Sonarr, and the rest of the *arr stack. My AI assistant just didn't know it existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;AI assistants are great generalists but terrible specialists. They'll try raw API calls, shell scripts, whatever they can think of. Anything except the purpose-built tool sitting right there. They don't read your README. They don't know your CLI flags. They improvise, and improvisation with infrastructure is how things break.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution: AgentSkills
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://clawhub.ai" rel="noopener noreferrer"&gt;ClawHub&lt;/a&gt; is a skill registry for &lt;a href="https://openclaw.ai" rel="noopener noreferrer"&gt;OpenClaw&lt;/a&gt; agents. A "skill" is a SKILL.md file that teaches an AI how to use a specific tool: what commands exist, when to use them, safety rules, and common workflows.&lt;/p&gt;

&lt;p&gt;I wrote one for Tsarr. It's structured like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;skill/
├── SKILL.md                          # Entry point: routing + safety rules
└── references/
    ├── setup.md                      # Installation, config, connectivity
    ├── common-workflows.md           # Health checks, search, add, queue, history
    └── service-cheatsheet.md         # Every command mapped per service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SKILL.md tells the agent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start&lt;/strong&gt; with &lt;code&gt;tsarr doctor&lt;/code&gt; if anything seems off&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;--json&lt;/code&gt;&lt;/strong&gt; when extracting data&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inspect before mutating&lt;/strong&gt;: always fetch before delete&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask which service&lt;/strong&gt; if the user just says "library" without specifying Radarr or Sonarr&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The references give it a cheatsheet of every command pattern so it doesn't have to guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;p&gt;Same question, "what series are the largest?", now gets answered instantly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tsarr sonarr series list &lt;span class="nt"&gt;--json&lt;/span&gt;
&lt;span class="c"&gt;# sorted, filtered, exact byte counts, all metadata&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No fumbling. No five attempts. The AI knows the tool because I taught it.&lt;/p&gt;

&lt;p&gt;Installing the skill on any OpenClaw instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx clawhub &lt;span class="nb"&gt;install &lt;/span&gt;tsarr
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One command. The agent immediately knows how to manage your entire *arr stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Can Do Now
&lt;/h2&gt;

&lt;p&gt;With the skill installed, I just ask in plain English and the agent handles it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"What's eating my storage?"&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tsarr sonarr series list &lt;span class="nt"&gt;--json&lt;/span&gt;  &lt;span class="c"&gt;# sorted by size, instant answer&lt;/span&gt;
tsarr radarr movie list &lt;span class="nt"&gt;--json&lt;/span&gt;   &lt;span class="c"&gt;# same for movies&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;"Are there any download issues?"&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tsarr sonarr queue list          &lt;span class="c"&gt;# stuck downloads, warnings, errors&lt;/span&gt;
tsarr radarr queue list          &lt;span class="c"&gt;# same for movies&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;"Check if everything is healthy"&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tsarr doctor                     &lt;span class="c"&gt;# tests all configured services at once&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;"Find me that new show everyone's talking about"&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tsarr sonarr series search &lt;span class="s2"&gt;"Shogun"&lt;/span&gt;
tsarr sonarr series add &amp;lt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nt"&gt;--quality-profile&lt;/span&gt; HD &lt;span class="nt"&gt;--root-folder&lt;/span&gt; /media/shows
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;"Are subtitles missing for anything?"&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tsarr bazarr movie list &lt;span class="nt"&gt;--json&lt;/span&gt;   &lt;span class="c"&gt;# check subtitle status across your library&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;"What got added recently?"&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tsarr sonarr &lt;span class="nb"&gt;history &lt;/span&gt;list        &lt;span class="c"&gt;# recent grabs, imports, upgrades&lt;/span&gt;
tsarr radarr &lt;span class="nb"&gt;history &lt;/span&gt;list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tsarr supports six services: &lt;strong&gt;Radarr&lt;/strong&gt; (movies), &lt;strong&gt;Sonarr&lt;/strong&gt; (TV), &lt;strong&gt;Lidarr&lt;/strong&gt; (music), &lt;strong&gt;Readarr&lt;/strong&gt; (books), &lt;strong&gt;Prowlarr&lt;/strong&gt; (indexers), and &lt;strong&gt;Bazarr&lt;/strong&gt; (subtitles). Every service follows the same CLI pattern, so the agent learns one and knows them all.&lt;/p&gt;

&lt;p&gt;The skill doesn't just list commands. It teaches the agent &lt;em&gt;when&lt;/em&gt; to use each one, what flags matter, and when to ask clarifying questions before doing anything destructive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Matters
&lt;/h2&gt;

&lt;p&gt;Every homelab runner, every DevOps engineer, every developer has tools their AI doesn't know about. Custom CLIs, internal scripts, niche utilities. The knowledge gap between "this tool exists" and "the AI can use it effectively" is just a SKILL.md file.&lt;/p&gt;

&lt;p&gt;If you've built a CLI that you use regularly, consider writing a skill for it. Your future self (and your AI) will thank you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tsarr:&lt;/strong&gt; &lt;a href="https://github.com/robbeverhelst/Tsarr" rel="noopener noreferrer"&gt;github.com/robbeverhelst/Tsarr&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;ClawHub skill:&lt;/strong&gt; &lt;a href="https://clawhub.ai/robbeverhelst/tsarr" rel="noopener noreferrer"&gt;clawhub.ai/robbeverhelst/tsarr&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;OpenClaw:&lt;/strong&gt; &lt;a href="https://openclaw.ai" rel="noopener noreferrer"&gt;openclaw.ai&lt;/a&gt;&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>ai</category>
      <category>homelab</category>
      <category>openclaw</category>
    </item>
    <item>
      <title>Guard Wolves: a Minecraft plugin I shouldn't have been able to build</title>
      <dc:creator>Robbe Verhelst</dc:creator>
      <pubDate>Fri, 13 Mar 2026 16:16:57 +0000</pubDate>
      <link>https://dev.to/robbeverhelst/guard-wolves-a-minecraft-plugin-i-shouldnt-have-been-able-to-build-poc</link>
      <guid>https://dev.to/robbeverhelst/guard-wolves-a-minecraft-plugin-i-shouldnt-have-been-able-to-build-poc</guid>
      <description>&lt;p&gt;I run a small Minecraft server for friends. We had a problem: every time we'd go exploring, mobs would wreck our base. The obvious solution? Guard dogs. Minecraft has wolves, they can be tamed, but tamed wolves are useless as guards. They either sit there doing nothing or follow you around like lost puppies.&lt;/p&gt;

&lt;p&gt;I wanted wolves that would stay at a location, patrol a radius, and attack hostile mobs. Basically guard dogs. Minecraft doesn't have that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The language I'd never seen
&lt;/h2&gt;

&lt;p&gt;The way to script custom behavior in Minecraft (without writing a full Java plugin) is &lt;a href="https://denizenscript.com/" rel="noopener noreferrer"&gt;DenizenScript&lt;/a&gt;. It's a YAML-like scripting language specific to Minecraft servers. It has its own syntax, its own commands, its own way of handling entities, events, and flags.&lt;/p&gt;

&lt;p&gt;I'd never written a line of it. My day job is TypeScript. DenizenScript looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;if &amp;lt;[wolf].location.distance[&amp;lt;[guard_center]&amp;gt;]&amp;gt; &amp;gt; 15&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;walk &amp;lt;[wolf]&amp;gt; &amp;lt;[safe_center]&amp;gt; speed:0.3&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;flag &amp;lt;[wolf]&amp;gt; return_attempts:++&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's not YAML. That's not any language I know. The angle brackets, the dot-chained methods, the flag system. It's its own thing entirely.&lt;/p&gt;

&lt;p&gt;Learning DenizenScript properly for one project didn't make sense. I'd use it once and forget it. So I described what I wanted in plain English and let AI translate it into DenizenScript.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I asked for
&lt;/h2&gt;

&lt;p&gt;The concept was simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Right-click a tamed wolf with a stick to toggle guard mode&lt;/li&gt;
&lt;li&gt;Guard wolf stays at that location and patrols a 15-block radius&lt;/li&gt;
&lt;li&gt;It attacks hostile mobs but never players, never creepers (explosions near the base = bad), and never passive animals&lt;/li&gt;
&lt;li&gt;If it gets stuck or wanders too far, teleport it back&lt;/li&gt;
&lt;li&gt;When the owner comes back, right-click with a stick to get your wolf back&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Simple on paper. 463 lines of DenizenScript in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The easy part
&lt;/h2&gt;

&lt;p&gt;AI handled the basic structure well. The event handlers, the combat scanning, the flag system for tracking guard state. Within an hour I had a script that could toggle guard mode and make wolves attack nearby zombies.&lt;/p&gt;

&lt;p&gt;The owner removal trick was clever: when a wolf enters guard mode, you remove the owner (so it stops following you and won't teleport to you), but save the original owner in a flag. When you disable guard mode, restore the owner. AI figured that out without me asking.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Enter guard mode&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;flag &amp;lt;[wolf]&amp;gt; original_owner:&amp;lt;player&amp;gt;&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;adjust &amp;lt;[wolf]&amp;gt; owner:!&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;flag &amp;lt;[wolf]&amp;gt; guard_mode&lt;/span&gt;

&lt;span class="c1"&gt;# Exit guard mode&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;adjust &amp;lt;[wolf]&amp;gt; owner:&amp;lt;[wolf].flag[original_owner]&amp;gt;&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;flag &amp;lt;[wolf]&amp;gt; guard_mode:!&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The hard part
&lt;/h2&gt;

&lt;p&gt;Then reality hit. The script worked in theory. In practice, wolves are dumb.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pathfinding failures.&lt;/strong&gt; Wolves would try to walk back to their guard point and get stuck on a fence. Or a half-slab. Or literally nothing visible. The AI-generated "walk back" command wasn't enough. I needed stuck detection: if a wolf hasn't moved in 60 seconds despite being told to walk, teleport it home.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;if &amp;lt;[time_stuck]&amp;gt; &amp;gt; 60&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;teleport &amp;lt;[wolf]&amp;gt; &amp;lt;[guard_center]&amp;gt;&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;flag &amp;lt;[wolf]&amp;gt; invulnerable_until:&amp;lt;util.time_now.add[3s]&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Teleport safety.&lt;/strong&gt; Teleporting a wolf sounds simple. But if you teleport it into a block, it suffocates. If you teleport it mid-air, fall damage. If you teleport it while a mob is hitting it, it dies during the invulnerability gap. Every teleport needed a 3-second invulnerability window with protection from multiple damage types.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The creeper problem.&lt;/strong&gt; First version: wolves attacked everything hostile. I realized pretty quickly that creepers would be a disaster. Wolf attacks creeper, creeper explodes, half your base is gone. Worse than no guard wolf at all. Had to explicitly exclude creepers from the target list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Server restarts.&lt;/strong&gt; Guard wolves need to survive server restarts. Their guard points, health, and state all stored in Denizen flags. But on restart, max health resets to vanilla values. Needed a startup script that re-discovers all guard wolves and restores their stats.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Health management.&lt;/strong&gt; Tamed wolves in Minecraft can be healed by feeding them meat. But guard wolves have their owner removed (that's how they stay put). Without an owner, the healing mechanic doesn't work the same way. I had to add a warning message: "Disable guard mode first, then heal, then re-enable."&lt;/p&gt;

&lt;p&gt;Each of these took multiple deploy-test-fix cycles. Change the script, reload on the server, spawn some zombies, watch the wolf, see what breaks, repeat.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the result looks like
&lt;/h2&gt;

&lt;p&gt;After all the iteration, the plugin is actually solid:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Wolves patrol a 15-block radius and return to their post&lt;/li&gt;
&lt;li&gt;They attack zombies, skeletons, spiders, and other hostiles on sight&lt;/li&gt;
&lt;li&gt;They ignore creepers, players, and passive mobs&lt;/li&gt;
&lt;li&gt;They survive server restarts with all their stats intact&lt;/li&gt;
&lt;li&gt;Stuck wolves teleport home with invulnerability&lt;/li&gt;
&lt;li&gt;You get death notifications when a guard wolf dies&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;/gw ls&lt;/code&gt; command shows all your wolves with health, location, and armor status
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;========== Your Wolves (5) ==========
• Fang (Pale) ❤ 32/40 GUARDING at -487,78,-18,world
• Rex (Ashen) ❤ 20/20 Following at 123,65,456,world
• Scout (Woods) ❤ 8/8 Sitting at -200,70,300,world
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The unexpected payoff
&lt;/h2&gt;

&lt;p&gt;Once the guard wolves script was solid, something clicked. I had 463 lines of working DenizenScript that covered events, flags, entity management, combat, persistence. It was basically a reference implementation.&lt;/p&gt;

&lt;p&gt;When I wanted other scripts afterward, like keeping dolphins alive (they drown when you wander too far from them), I could point AI at the guard wolves script and say "make something like this but for dolphins." It already understood the patterns: how Denizen handles entities, how flags work, how to hook into server events.&lt;/p&gt;

&lt;p&gt;The first script took a day. The dolphin script took maybe 20 minutes. Having a working example in the same language made AI dramatically better at generating new scripts. It's like giving it a style guide instead of asking it to figure everything out from scratch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest breakdown
&lt;/h2&gt;

&lt;p&gt;AI got me maybe 60-70% of the way there. The basic structure, the event system, the flag management. Things that are well-documented and pattern-based.&lt;/p&gt;

&lt;p&gt;The remaining 30-40% was all edge cases that only surface when you actually run the thing. Pathfinding quirks, teleport safety, combat targeting rules, persistence across restarts. AI can't predict that wolves get stuck on half-slabs or that creepers near your base are a disaster. That knowledge comes from testing.&lt;/p&gt;

&lt;p&gt;Total time: about a day. Without AI, I probably wouldn't have built it at all. DenizenScript is too niche to justify learning for one project. With AI, I could focus on &lt;em&gt;what&lt;/em&gt; I wanted instead of &lt;em&gt;how&lt;/em&gt; DenizenScript works.&lt;/p&gt;

&lt;p&gt;The plugin is open source if you want to use it or build on it: &lt;a href="https://github.com/robbeverhelst/guard-wolves" rel="noopener noreferrer"&gt;guard-wolves on GitHub&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Anyone else building weird Minecraft plugins? I'd love to see what people are doing with DenizenScript.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>minecraft</category>
      <category>gamedev</category>
      <category>opensource</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Tsarr: a type-safe TypeScript SDK &amp; CLI for the Servarr ecosystem</title>
      <dc:creator>Robbe Verhelst</dc:creator>
      <pubDate>Fri, 13 Mar 2026 15:00:13 +0000</pubDate>
      <link>https://dev.to/robbeverhelst/tsarr-a-type-safe-typescript-sdk-cli-for-the-servarr-ecosystem-55n4</link>
      <guid>https://dev.to/robbeverhelst/tsarr-a-type-safe-typescript-sdk-cli-for-the-servarr-ecosystem-55n4</guid>
      <description>&lt;p&gt;If you run Radarr, Sonarr, or any of the *arr apps, you've probably written a quick script to automate something. Bulk imports, library cleanup, monitoring. And you've probably hit the same wall: the APIs are big, undocumented in practice, and every app has slightly different conventions.&lt;/p&gt;

&lt;p&gt;I got tired of writing raw fetch calls and guessing at response shapes, so I built &lt;strong&gt;Tsarr&lt;/strong&gt;: a fully type-safe TypeScript client and CLI that covers the entire Servarr ecosystem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;Tsarr provides auto-generated TypeScript clients for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Radarr&lt;/strong&gt; (movies)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sonarr&lt;/strong&gt; (TV series)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lidarr&lt;/strong&gt; (music)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Readarr&lt;/strong&gt; (books)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prowlarr&lt;/strong&gt; (indexers)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bazarr&lt;/strong&gt; (subtitles)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every client is generated directly from the official Swagger/OpenAPI specs, so types are always accurate and up-to-date. When the *arr devs update their API, Tsarr follows automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works under the hood
&lt;/h2&gt;

&lt;p&gt;Each *arr app ships an OpenAPI/Swagger spec that describes their full API. Tsarr uses &lt;a href="https://github.com/hey-api/openapi-ts" rel="noopener noreferrer"&gt;@hey-api/openapi-ts&lt;/a&gt; to generate typed TypeScript clients from those specs.&lt;/p&gt;

&lt;p&gt;The flow looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fetch the latest OpenAPI specs from each *arr app's GitHub repo&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;@hey-api/openapi-ts&lt;/code&gt; to generate fully typed clients (request types, response types, enums, everything)&lt;/li&gt;
&lt;li&gt;Bundle it all into one package with modular imports per app&lt;/li&gt;
&lt;li&gt;The CLI layer wraps those same clients with &lt;a href="https://github.com/unjs/citty" rel="noopener noreferrer"&gt;citty&lt;/a&gt; for the command-line interface&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because the generation is automated, keeping up with API changes is just a matter of re-running the generator. Renovate handles dependency bumps, and semantic-release cuts new versions automatically when PRs merge.&lt;/p&gt;

&lt;p&gt;No hand-written API types. No manual maintenance. If the official spec changes, the next build catches it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use it as an SDK
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;RadarrClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SonarrClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tsarr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;radarr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RadarrClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:7878&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;your-api-key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Full type safety: autocomplete, type checking, the works&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;movies&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;radarr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getMovies&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;radarr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSystemStatus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get proper TypeScript types for every request and response. No more guessing what fields a movie object has.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use it as a CLI
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install globally&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; tsarr

&lt;span class="c"&gt;# Setup&lt;/span&gt;
tsarr config init

&lt;span class="c"&gt;# Manage your library from the terminal&lt;/span&gt;
tsarr radarr movie list
tsarr radarr movie search &lt;span class="nt"&gt;--term&lt;/span&gt; &lt;span class="s2"&gt;"Interstellar"&lt;/span&gt;
tsarr sonarr series list
tsarr prowlarr indexer list

&lt;span class="c"&gt;# Check all connections at once&lt;/span&gt;
tsarr doctor

&lt;span class="c"&gt;# JSON output for scripting&lt;/span&gt;
tsarr radarr movie list &lt;span class="nt"&gt;--json&lt;/span&gt; | jq &lt;span class="s1"&gt;'.[] | .title'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI supports table output for humans and JSON for scripts. Shell completions included for bash, zsh, and fish.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I built this
&lt;/h2&gt;

&lt;p&gt;I self-host everything on a Kubernetes cluster at home. When you manage multiple *arr instances, automation isn't optional. I also run an AI assistant that manages my homelab, and it works way better with CLIs than raw APIs. Being able to say "add Interstellar to Radarr" and have it run &lt;code&gt;tsarr radarr movie add&lt;/code&gt; behind the scenes is a game changer.&lt;/p&gt;

&lt;p&gt;So I needed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Type safety&lt;/strong&gt; so I catch API changes at compile time, not at 3 AM when my automation breaks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One package&lt;/strong&gt; instead of six different libraries for six different apps&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CLI access&lt;/strong&gt; because sometimes you just want to check something from the terminal without opening a browser&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nothing like this existed for TypeScript. &lt;a href="https://github.com/Dark-Alex-17/managarr" rel="noopener noreferrer"&gt;Managarr&lt;/a&gt; is a great Rust TUI for interactive management, but if your stack is Node.js/TypeScript and you want to build automation, you were on your own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;

&lt;p&gt;Available everywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# npm&lt;/span&gt;
npm &lt;span class="nb"&gt;install &lt;/span&gt;tsarr

&lt;span class="c"&gt;# Homebrew&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;robbeverhelst/tsarr/tsarr

&lt;span class="c"&gt;# Docker&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; ghcr.io/robbeverhelst/tsarr doctor

&lt;span class="c"&gt;# AUR (Arch Linux)&lt;/span&gt;
yay &lt;span class="nt"&gt;-S&lt;/span&gt; tsarr-bin

&lt;span class="c"&gt;# Nix&lt;/span&gt;
nix profile &lt;span class="nb"&gt;install &lt;/span&gt;github:robbeverhelst/tsarr?dir&lt;span class="o"&gt;=&lt;/span&gt;packaging/nix

&lt;span class="c"&gt;# Or grab a standalone binary, no runtime needed&lt;/span&gt;
curl &lt;span class="nt"&gt;-L&lt;/span&gt; https://github.com/robbeverhelst/tsarr/releases/latest/download/tsarr-linux-x64 &lt;span class="nt"&gt;-o&lt;/span&gt; tsarr
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What you can build with it
&lt;/h2&gt;

&lt;p&gt;Some things I use it for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bulk movie imports&lt;/strong&gt;: feed it a list, let it add everything with proper quality profiles&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Library audits&lt;/strong&gt;: find movies without subtitles, series with missing episodes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitoring scripts&lt;/strong&gt;: check queue status, disk space, indexer health in cron jobs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-app automation&lt;/strong&gt;: when Sonarr grabs a series, trigger Bazarr to fetch subtitles&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The CLI is also great for quick checks without leaving the terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# What's in my download queue right now?&lt;/span&gt;
tsarr radarr queue list &lt;span class="nt"&gt;--table&lt;/span&gt;

&lt;span class="c"&gt;# Any indexer issues?&lt;/span&gt;
tsarr prowlarr indexer list &lt;span class="nt"&gt;--table&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  It's open source
&lt;/h2&gt;

&lt;p&gt;MIT licensed. Contributions welcome.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/robbeverhelst/Tsarr" rel="noopener noreferrer"&gt;robbeverhelst/Tsarr&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;npm:&lt;/strong&gt; &lt;a href="https://www.npmjs.com/package/tsarr" rel="noopener noreferrer"&gt;tsarr&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docs:&lt;/strong&gt; &lt;a href="https://robbeverhelst.github.io/Tsarr/" rel="noopener noreferrer"&gt;robbeverhelst.github.io/Tsarr&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you run any *arr apps and write TypeScript, give it a try. Issues and PRs welcome, especially if you find edge cases in specific API endpoints.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What's your *arr automation setup like? I'd love to hear what people are building.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>opensource</category>
      <category>cli</category>
      <category>selfhosted</category>
    </item>
  </channel>
</rss>
