<?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>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>
