<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>pven</title>
    <link>https://unprompted.pven.com/</link>
    <description>Homelab notes on Claude, automation, and self-hosted tools.</description>
    <pubDate>Wed, 08 Jul 2026 16:49:22 +0000</pubDate>
    <item>
      <title>One File, Zero Chaos</title>
      <link>https://unprompted.pven.com/one-file-zero-chaos</link>
      <description>&lt;![CDATA[It started, as these things do, with a key rotation.&#xA;&#xA;I run a small homelab: an Ubuntu server, a pile of Docker Compose stacks, and somewhere around thirty self-hosted services: Radarr, Sonarr, Plex, Home Assistant, Grafana, ntfy, the usual suspects. And like every homelabber, I write a lot of small scripts. Check disk usage, ping an API, send a notification when a backup finishes or a disk starts making that noise disks shouldn&#39;t make.&#xA;&#xA;Every one of those scripts needed something: an API key, a URL, a chatID. And every time I made the same decision: &#34;I&#39;ll just hardcode it here for now.&#34; Now, &#34;for now&#34; in a homelab is a unit of time that rounds up to forever.&#xA;&#xA;The day I decided to rotate my Telegram bot token, I discovered it was copy/pasted into six different files. Rotating it meant grepping my entire scripts folder and hoping I didn&#39;t miss one. (I missed one. My disk-usage alert went silent for two weeks. The disk was fine btw.)&#xA;&#xA;This is exactly the kind of sloppy chaos a homelab collects if you don&#39;t stop it early. So I stopped it.&#xA;&#xA;Why not BitWarden / Vault / 1Password / ...?&#xA;&#xA;Because this is a single-user homelab, not a company.&#xA;&#xA;HashiCorp Vault for example is a nice piece of software that would also be, in my house, a self-hosted service whose entire job is to be a dependency for my other self-hosted services. One more thing to run, back up, unseal, and debug at 11pm when notifications stop working and I can&#39;t tell whether the problem is my script or my secrets infrastructure.&#xA;&#xA;My requirements were shorter than most tools&#39; install instructions: one place for all config and secrets, readable by any bash or Python script, and simple enough that I can read the whole thing and fix it in 30 seconds at 11pm. That last one is the real requirement. Everything else is negotiable.&#xA;&#xA;So I did the least fashionable thing possible: one INI file and a tiny bash helper.&#xA;&#xA;The setup&#xA;&#xA;All config lives in /etc/mini/mini.conf.ini, one section per service or topic:&#xA;&#xA;[telegram]&#xA;bottoken = 123456:FAKE-EXAMPLE-TOKEN&#xA;chatid = 999999999&#xA;&#xA;[plex]&#xA;url = https://plex.example.local:32400&#xA;apitoken = fake-example-token&#xA;&#xA;[defaults]&#xA;timeout = 20&#xA;&#xA;(All illustrative, obviously. My real chatID has way more nines.)&#xA;&#xA;Any script that needs a value calls miniconf section key:&#xA;&#xA;TOKEN=$(miniconf telegram bottoken)&#xA;curl -s &#34;https://api.telegram.org/bot$TOKEN/sendMessage&#34; ...&#xA;&#xA;That&#39;s the whole interface. The script doesn&#39;t know or care where config comes from. It just asks. Rotating a key is now a one-line edit in one file, and every script picks it up on the next run.&#xA;&#xA;The script&#xA;&#xA;Here&#39;s miniconf in full. About 40 lines, no dependencies beyond bash and awk:&#xA;&#xA;!/usr/bin/env bash&#xA;creator: pven, supported by Claude&#xA;version: v1.3&#xA;set -euo pipefail&#xA;&#xA;INI=&#34;${MINIINI:-/etc/mini/mini.conf.ini}&#34;&#xA;&#xA;usage: miniconf section key [default]&#xA;miniconf() {&#xA;  local section=&#34;$1&#34; key=&#34;$2&#34; def=&#34;${3-}&#34; val=&#34;&#34;&#xA;&#xA;  # ENV override: MINISECTIONKEY&#xA;  local envkey=&#34;MINI${section}${key}&#34;&#xA;  envkey=&#34;$(echo &#34;$envkey&#34; | tr &#39;[:lower:]-&#39; &#39;[:upper:]&#39;)&#34;&#xA;  if [[ -n &#34;${!envkey-}&#34; ]]; then printf &#39;%s\n&#39; &#34;${!envkey}&#34;; return 0; fi&#xA;&#xA;  val=&#34;$(awk -v s=&#34;[$section]&#34; -v k=&#34;$key&#34; -v d=&#34;$def&#34; &#39;&#xA;    BEGIN{ FS=&#34;=&#34;; inside=0; s=tolower(s); k=tolower(k) }&#xA;    { sub(/\r$/,&#34;&#34;) }&#xA;    /^\s\[/ { if (tolower($0) == s) { inside=1 } else { inside=0 } }&#xA;    /^\s([#;]|$)/ { next }&#xA;    inside &amp;&amp; tolower($1) ~ &#34;^[[:space:]]&#34; k &#34;[[:space:]]$&#34; {&#xA;      sub(/^*=/,&#34;&#34;)&#xA;      gsub(/^[ \t]+|[ \t]+$/,&#34;&#34;)&#xA;      print&#xA;      found=1&#xA;      exit&#xA;    }&#xA;    END{ if (!found &amp;&amp; length(d)) print d }&#xA;  &#39; &#34;$INI&#34;)&#34;&#xA;&#xA;  if [[ -n &#34;$val&#34; ]]; then printf &#39;%s\n&#39; &#34;$val&#34;; else printf &#39;%s\n&#39; &#34;$def&#34;; fi&#xA;}&#xA;&#xA;miniconf &#34;$@&#34;&#xA;&#xA;Let&#39;s walk through the interesting parts.&#xA;&#xA;The awk INI parser&#xA;&#xA;The heavy lifting is one awk program. It splits lines on =, and tracks a single state variable: inside. Every time it sees a line starting with [, it checks whether that&#39;s the section we want and flips inside on or off. Comment lines (# or ;) and blank lines are skipped.&#xA;&#xA;When we&#39;re inside the right section and field one matches our key, it strips everything up to the first =, trims surrounding whitespace, prints the value, and exits. First match wins. If we fall off the end without a match, the END block prints the default (if one was given).&#xA;&#xA;Is it a complete INI parser? No. It doesn&#39;t do quoted values, multiline values, or interpolation. It also doesn&#39;t need to, because I control both ends of this transaction.&#xA;&#xA;Case-insensitive everything&#xA;&#xA;Notice the tolower() calls sprinkled everywhere: both the section header comparison and the key match are case-insensitive. This wasn&#39;t design foresight. This was me repeatedly writing [Telegram] in the config and miniconf telegram ... in scripts, then spending ten minutes debugging an empty variable. After the third time, I stopped fighting my own fingers and made the tool forgive me instead.&#xA;&#xA;That sub(/\r$/,&#34;&#34;) line&#xA;&#xA;Every line gets a carriage return stripped off the end. Why? Because at some point one config file made a round trip through a Windows editor, came back with CRLF line endings, and every value it returned had an invisible \r glued to the end. Nothing fails quite as quietly as an API token with a carriage return in it. One sub() later, the problem can never come back. Cheap insurance.&#xA;&#xA;The env-var override (the part I&#39;m actually proud of)&#xA;&#xA;Look at the top of the function, before awk ever runs:&#xA;&#xA;local envkey=&#34;MINI${section}${key}&#34;&#xA;envkey=&#34;$(echo &#34;$envkey&#34; | tr &#39;[:lower:]-&#39; &#39;[:upper:]&#39;)&#34;&#xA;if [[ -n &#34;${!envkey-}&#34; ]]; then printf &#39;%s\n&#39; &#34;${!envkey}&#34;; return 0; fi&#xA;&#xA;Any config value can be overridden at runtime by setting MINISECTIONKEY in the environment. Section and key get uppercased, dashes become underscores, and bash&#39;s indirect expansion (${!envkey}) checks whether that variable exists. If it does, that wins and the file is never touched.&#xA;&#xA;This sounds like a small thing. It is not a small thing. It means I can test a notification script against my own private chat instead of the family group:&#xA;&#xA;MINITELEGRAMCHATID=123 ./notify.sh&#xA;&#xA;No editing the config file, no &#34;temporary&#34; change I forget to revert, no accidentally spamming everyone at 11pm because I was debugging. One-off overrides, testing, pointing a script at a staging instance: all without touching the file that thirty other scripts depend on. Twelve-factor apps get this via environment config; my crontab gets it via three lines of bash.&#xA;&#xA;(The MINIINI variable at the top is the same trick applied to the file itself: point the whole tool at a test config when you want to.)&#xA;&#xA;What this fixed, philosophically&#xA;&#xA;miniconf fits a broader rule I try to hold my homelab to: simple tools I fully understand beat powerful tools I half understand. One boring config file instead of one secret per script. A helper small enough that &#34;read the source&#34; is the documentation.&#xA;&#xA;The chaos hasn&#39;t come back. New script? Two lines: call miniconf, use the value. Key rotation is a one-file edit. And when something breaks at 11pm, the config layer is never the suspect. It&#39;s 40 lines of bash I can hold in my head.&#xA;&#xA;The best tools are the boring ones. miniconf has been running for months and I mostly forget it exists. For infrastructure, that&#39;s the highest compliment there is.&#xA;&#xA;---&#xA;Written with assistance from Claude.&#xA;&#xA;---&#xD;&#xA;Want to reach out? Contact me._]]&gt;</description>
      <content:encoded><![CDATA[<p>It started, as these things do, with a key rotation.</p>

<p>I run a small homelab: an Ubuntu server, a pile of Docker Compose stacks, and somewhere around thirty self-hosted services: Radarr, Sonarr, Plex, Home Assistant, Grafana, ntfy, the usual suspects. And like every homelabber, I write a lot of small scripts. Check disk usage, ping an API, send a notification when a backup finishes or a disk starts making that noise disks shouldn&#39;t make.</p>

<p>Every one of those scripts needed something: an API key, a URL, a chatID. And every time I made the same decision: “I&#39;ll just hardcode it here for now.” Now, “for now” in a homelab is a unit of time that rounds up to forever.</p>

<p>The day I decided to rotate my Telegram bot token, I discovered it was copy/pasted into six different files. Rotating it meant grepping my entire scripts folder and hoping I didn&#39;t miss one. (I missed one. My disk-usage alert went silent for two weeks. The disk was fine btw.)</p>

<p>This is exactly the kind of sloppy chaos a homelab collects if you don&#39;t stop it early. So I stopped it.</p>

<h2 id="why-not-bitwarden-vault-1password">Why not BitWarden / Vault / 1Password / ...?</h2>

<p>Because this is a single-user homelab, not a company.</p>

<p>HashiCorp Vault for example is a nice piece of software that would also be, in my house, a self-hosted service whose entire job is to be a dependency for my other self-hosted services. One more thing to run, back up, unseal, and debug at 11pm when notifications stop working and I can&#39;t tell whether the problem is my script or my secrets infrastructure.</p>

<p>My requirements were shorter than most tools&#39; install instructions: one place for all config and secrets, readable by any bash or Python script, and simple enough that I can read the whole thing and fix it in 30 seconds at 11pm. That last one is the real requirement. Everything else is negotiable.</p>

<p>So I did the least fashionable thing possible: one INI file and a tiny bash helper.</p>

<h2 id="the-setup">The setup</h2>

<p>All config lives in <code>/etc/mini/mini.conf.ini</code>, one section per service or topic:</p>

<pre><code class="language-ini">[telegram]
bot_token = 123456:FAKE-EXAMPLE-TOKEN
chat_id = 999999999

[plex]
url = https://plex.example.local:32400
api_token = fake-example-token

[defaults]
timeout = 20
</code></pre>

<p>(All illustrative, obviously. My real chatID has way more nines.)</p>

<p>Any script that needs a value calls <code>mini_conf &lt;section&gt; &lt;key&gt;</code>:</p>

<pre><code class="language-bash">TOKEN=$(mini_conf telegram bot_token)
curl -s &#34;https://api.telegram.org/bot$TOKEN/sendMessage&#34; ...
</code></pre>

<p>That&#39;s the whole interface. The script doesn&#39;t know or care where config comes from. It just asks. Rotating a key is now a one-line edit in one file, and every script picks it up on the next run.</p>

<h2 id="the-script">The script</h2>

<p>Here&#39;s <code>mini_conf</code> in full. About 40 lines, no dependencies beyond bash and awk:</p>

<pre><code class="language-bash">#!/usr/bin/env bash
# creator: pven, supported by Claude
# version: v1.3
set -euo pipefail

INI=&#34;${MINI_INI:-/etc/mini/mini.conf.ini}&#34;

# usage: mini_conf &lt;section&gt; &lt;key&gt; [default]
mini_conf() {
  local section=&#34;$1&#34; key=&#34;$2&#34; def=&#34;${3-}&#34; val=&#34;&#34;

  # ENV override: MINI_&lt;SECTION&gt;_&lt;KEY&gt;
  local envkey=&#34;MINI_${section}_${key}&#34;
  envkey=&#34;$(echo &#34;$envkey&#34; | tr &#39;[:lower:]-&#39; &#39;[:upper:]_&#39;)&#34;
  if [[ -n &#34;${!envkey-}&#34; ]]; then printf &#39;%s\n&#39; &#34;${!envkey}&#34;; return 0; fi

  val=&#34;$(awk -v s=&#34;[$section]&#34; -v k=&#34;$key&#34; -v d=&#34;$def&#34; &#39;
    BEGIN{ FS=&#34;=&#34;; inside=0; s=tolower(s); k=tolower(k) }
    { sub(/\r$/,&#34;&#34;) }
    /^\s*\[/ { if (tolower($0) == s) { inside=1 } else { inside=0 } }
    /^\s*([#;]|$)/ { next }
    inside &amp;&amp; tolower($1) ~ &#34;^[[:space:]]*&#34; k &#34;[[:space:]]*$&#34; {
      sub(/^[^=]*=/,&#34;&#34;)
      gsub(/^[ \t]+|[ \t]+$/,&#34;&#34;)
      print
      found=1
      exit
    }
    END{ if (!found &amp;&amp; length(d)) print d }
  &#39; &#34;$INI&#34;)&#34;

  if [[ -n &#34;$val&#34; ]]; then printf &#39;%s\n&#39; &#34;$val&#34;; else printf &#39;%s\n&#39; &#34;$def&#34;; fi
}

mini_conf &#34;$@&#34;
</code></pre>

<p>Let&#39;s walk through the interesting parts.</p>

<h3 id="the-awk-ini-parser">The awk INI parser</h3>

<p>The heavy lifting is one awk program. It splits lines on <code>=</code>, and tracks a single state variable: <code>inside</code>. Every time it sees a line starting with <code>[</code>, it checks whether that&#39;s the section we want and flips <code>inside</code> on or off. Comment lines (<code>#</code> or <code>;</code>) and blank lines are skipped.</p>

<p>When we&#39;re <code>inside</code> the right section and field one matches our key, it strips everything up to the first <code>=</code>, trims surrounding whitespace, prints the value, and exits. First match wins. If we fall off the end without a match, the <code>END</code> block prints the default (if one was given).</p>

<p>Is it a complete INI parser? No. It doesn&#39;t do quoted values, multiline values, or interpolation. It also doesn&#39;t need to, because I control both ends of this transaction.</p>

<h3 id="case-insensitive-everything">Case-insensitive everything</h3>

<p>Notice the <code>tolower()</code> calls sprinkled everywhere: both the section header comparison and the key match are case-insensitive. This wasn&#39;t design foresight. This was me repeatedly writing <code>[Telegram]</code> in the config and <code>mini_conf telegram ...</code> in scripts, then spending ten minutes debugging an empty variable. After the third time, I stopped fighting my own fingers and made the tool forgive me instead.</p>

<h3 id="that-sub-r-line">That <code>sub(/\r$/,&#34;&#34;)</code> line</h3>

<p>Every line gets a carriage return stripped off the end. Why? Because at some point one config file made a round trip through a Windows editor, came back with CRLF line endings, and every value it returned had an invisible <code>\r</code> glued to the end. Nothing fails quite as quietly as an API token with a carriage return in it. One <code>sub()</code> later, the problem can never come back. Cheap insurance.</p>

<h3 id="the-env-var-override-the-part-i-m-actually-proud-of">The env-var override (the part I&#39;m actually proud of)</h3>

<p>Look at the top of the function, before awk ever runs:</p>

<pre><code class="language-bash">local envkey=&#34;MINI_${section}_${key}&#34;
envkey=&#34;$(echo &#34;$envkey&#34; | tr &#39;[:lower:]-&#39; &#39;[:upper:]_&#39;)&#34;
if [[ -n &#34;${!envkey-}&#34; ]]; then printf &#39;%s\n&#39; &#34;${!envkey}&#34;; return 0; fi
</code></pre>

<p>Any config value can be overridden at runtime by setting <code>MINI_&lt;SECTION&gt;_&lt;KEY&gt;</code> in the environment. Section and key get uppercased, dashes become underscores, and bash&#39;s indirect expansion (<code>${!envkey}</code>) checks whether that variable exists. If it does, that wins and the file is never touched.</p>

<p>This sounds like a small thing. It is not a small thing. It means I can test a notification script against my own private chat instead of the family group:</p>

<pre><code class="language-bash">MINI_TELEGRAM_CHAT_ID=123 ./notify.sh
</code></pre>

<p>No editing the config file, no “temporary” change I forget to revert, no accidentally spamming everyone at 11pm because I was debugging. One-off overrides, testing, pointing a script at a staging instance: all without touching the file that thirty other scripts depend on. Twelve-factor apps get this via environment config; my crontab gets it via three lines of bash.</p>

<p>(The <code>MINI_INI</code> variable at the top is the same trick applied to the file itself: point the whole tool at a test config when you want to.)</p>

<h2 id="what-this-fixed-philosophically">What this fixed, philosophically</h2>

<p><code>mini_conf</code> fits a broader rule I try to hold my homelab to: simple tools I fully understand beat powerful tools I half understand. One boring config file instead of one secret per script. A helper small enough that “read the source” is the documentation.</p>

<p>The chaos hasn&#39;t come back. New script? Two lines: call <code>mini_conf</code>, use the value. Key rotation is a one-file edit. And when something breaks at 11pm, the config layer is never the suspect. It&#39;s 40 lines of bash I can hold in my head.</p>

<p>The best tools are the boring ones. <code>mini_conf</code> has been running for months and I mostly forget it exists. For infrastructure, that&#39;s the highest compliment there is.</p>

<hr>

<p><em>Written with assistance from Claude.</em></p>

<hr>

<p><em>Want to reach out? <a href="https://contact.pven.com">Contact me</a>.</em></p>
]]></content:encoded>
      <guid>https://unprompted.pven.com/one-file-zero-chaos</guid>
      <pubDate>Sat, 04 Jul 2026 20:43:24 +0000</pubDate>
    </item>
    <item>
      <title>The right tool for the job</title>
      <link>https://unprompted.pven.com/the-right-tool-for-the-job</link>
      <description>&lt;![CDATA[I&#39;ve been using AI assistants long enough now to have developed some opinions about where each one actually shines and where it doesn&#39;t.&#xA;&#xA;At work, I use Microsoft 365 Copilot. Mostly not for writing or reasoning, but for one specific thing it does better than anything else: finding stuff in the Microsoft ecosystem. Need a change number from six months ago buried in a Teams conversation? Copilot finds it. Want to know what was decided in that SharePoint document nobody can locate? Copilot finds it. For anything outside that Microsoft bubble it&#39;s unremarkable, but inside it, it&#39;s genuinely useful.&#xA;&#xA;For technical work, I use Claude. Both through Claude Desktop and Claude Code running on my homelab. Writing scripts, working through infrastructure problems, reasoning about code. This is where it earns its keep. But I wouldn&#39;t ask it to do anything else.&#xA;&#xA;Then there&#39;s Gemini. I use it for exactly one thing: images. Generating images for my website, or taking an existing photo and seeing what happens when you ask it to do something creative with it. Claude is not good at this. Gemini is. Simple as that.&#xA;&#xA;What I&#39;ve arrived at isn&#39;t a single AI that does everything: it&#39;s three tools with three distinct jobs. Copilot owns the Microsoft cloud. Claude owns the technical work. Gemini owns the pixels.&#xA;&#xA;Maybe that changes as these tools evolve. But for now, this is what works.&#xA;&#xA;---&#xA;Written with assistance from Claude.&#xA;&#xA;---&#xD;&#xA;Want to reach out? Contact me.]]&gt;</description>
      <content:encoded><![CDATA[<p>I&#39;ve been using AI assistants long enough now to have developed some opinions about where each one actually shines and where it doesn&#39;t.</p>

<p>At work, I use Microsoft 365 Copilot. Mostly not for writing or reasoning, but for one specific thing it does better than anything else: finding stuff in the Microsoft ecosystem. Need a change number from six months ago buried in a Teams conversation? Copilot finds it. Want to know what was decided in that SharePoint document nobody can locate? Copilot finds it. For anything outside that Microsoft bubble it&#39;s unremarkable, but inside it, it&#39;s genuinely useful.</p>

<p>For technical work, I use Claude. Both through Claude Desktop and Claude Code running on my homelab. Writing scripts, working through infrastructure problems, reasoning about code. This is where it earns its keep. But I wouldn&#39;t ask it to do anything else.</p>

<p>Then there&#39;s Gemini. I use it for exactly one thing: images. Generating images for my website, or taking an existing photo and seeing what happens when you ask it to do something creative with it. Claude is not good at this. Gemini is. Simple as that.</p>

<p>What I&#39;ve arrived at isn&#39;t a single AI that does everything: it&#39;s three tools with three distinct jobs. Copilot owns the Microsoft cloud. Claude owns the technical work. Gemini owns the pixels.</p>

<p>Maybe that changes as these tools evolve. But for now, this is what works.</p>

<hr>

<p><em>Written with assistance from Claude.</em></p>

<hr>

<p><em>Want to reach out? <a href="https://contact.pven.com">Contact me</a>.</em></p>
]]></content:encoded>
      <guid>https://unprompted.pven.com/the-right-tool-for-the-job</guid>
      <pubDate>Tue, 16 Jun 2026 18:46:28 +0000</pubDate>
    </item>
    <item>
      <title>You don&#39;t need to master it. You need to understand it.</title>
      <link>https://unprompted.pven.com/you-dont-need-to-master-it</link>
      <description>&lt;![CDATA[There is a quiet shift happening in how people work with technology. You no longer need to know Bash, Python, or SQL by heart to get things done. AI will write it for you. And for the most part, it will write it correctly.&#xA;&#xA;I am not going to argue against that. I use it myself, constantly. But I want to make the case for something that I think gets lost in the enthusiasm: you still need to understand what you&#39;re executing.&#xA;&#xA;Not write it, but understand it. The difference matters more than it sounds.&#xA;&#xA;A tablespace, a well-known AI, and a near-disaster&#xA;&#xA;A while back I was working with an Oracle database and needed to verify whether a particular tablespace had existed at some point. I asked an AI assistant something like: &#34;how do I check if this tablespace existed?&#34;&#xA;&#xA;The AI came back with a DROP TABLESPACE command. Its logic was: try to drop it, and if it doesn&#39;t throw an error, it was there. As a detection method, it technically works.&#xA;&#xA;Except that if the tablespace does exist, you just deleted it.&#xA;&#xA;If you know Oracle, you immediately see the problem. If you don&#39;t, you paste that command into your terminal and walk away with data loss and a very bad afternoon. The AI wasn&#39;t wrong, exactly. It answered the question I asked. But it answered a slightly different version of it: one where &#34;existed&#34; meant &#34;still exists now, and can be destroyed as a test.&#34; I meant existed in the past, and wanted a safe way to find out.&#xA;&#xA;That is a distinction no AI will reliably catch if you don&#39;t catch it yourself.&#xA;&#xA;Understanding is the new minimum&#xA;&#xA;For a long time, the basic for working with databases, scripting, or systems administration was knowing the tools. You had to learn the syntax, the gotchas, the edge cases. That bar has genuinely lowered. AI handles a lot of that now.&#xA;&#xA;But the new minimum is understanding. You need to be able to read what AI gives you and know, at least roughly, what it does. Not because AI is unreliable, it is often very good, but because the failure modes are hard to spot. The command that destroys your data may look identical to the one that checks for it.&#xA;&#xA;The good news is that this is a much easier bar to clear. You don&#39;t need to be able to write a Python script from scratch. You need to be able to look at one and ask: does this do what I think it does? And if you&#39;re not sure, you can ask the AI to explain it. That conversation is usually excellent.&#xA;&#xA;Read what you&#39;re given. Ask for an explanation if something is unclear. Never run a command you couldn&#39;t describe in plain language, even roughly.&#xA;&#xA;The blast radius of misplaced trust is real. Understanding is what keeps it small.&#xA;&#xA;---&#xA;Written with assistance from Claude.&#xA;&#xA;---&#xD;&#xA;Want to reach out? Contact me.]]&gt;</description>
      <content:encoded><![CDATA[<p>There is a quiet shift happening in how people work with technology. You no longer need to know Bash, Python, or SQL by heart to get things done. AI will write it for you. And for the most part, it will write it correctly.</p>

<p>I am not going to argue against that. I use it myself, constantly. But I want to make the case for something that I think gets lost in the enthusiasm: you still need to understand what you&#39;re executing.</p>

<p>Not write it, but understand it. The difference matters more than it sounds.</p>

<h2 id="a-tablespace-a-well-known-ai-and-a-near-disaster">A tablespace, a well-known AI, and a near-disaster</h2>

<p>A while back I was working with an Oracle database and needed to verify whether a particular tablespace had existed at some point. I asked an AI assistant something like: “how do I check if this tablespace existed?”</p>

<p>The AI came back with a <code>DROP TABLESPACE</code> command. Its logic was: try to drop it, and if it doesn&#39;t throw an error, it was there. As a detection method, it technically works.</p>

<p>Except that if the tablespace <em>does</em> exist, you just deleted it.</p>

<p>If you know Oracle, you immediately see the problem. If you don&#39;t, you paste that command into your terminal and walk away with data loss and a very bad afternoon. The AI wasn&#39;t wrong, exactly. It answered the question I asked. But it answered a slightly different version of it: one where “existed” meant “still exists now, and can be destroyed as a test.” I meant existed in the past, and wanted a safe way to find out.</p>

<p>That is a distinction no AI will reliably catch if you don&#39;t catch it yourself.</p>

<h2 id="understanding-is-the-new-minimum">Understanding is the new minimum</h2>

<p>For a long time, the basic for working with databases, scripting, or systems administration was knowing the tools. You had to learn the syntax, the gotchas, the edge cases. That bar has genuinely lowered. AI handles a lot of that now.</p>

<p>But the new minimum is understanding. You need to be able to read what AI gives you and know, at least roughly, what it does. Not because AI is unreliable, it is often very good, but because the failure modes are hard to spot. The command that destroys your data may look identical to the one that checks for it.</p>

<p>The good news is that this is a much easier bar to clear. You don&#39;t need to be able to write a Python script from scratch. You need to be able to look at one and ask: does this do what I think it does? And if you&#39;re not sure, you can ask the AI to explain it. That conversation is usually excellent.</p>

<p>Read what you&#39;re given. Ask for an explanation if something is unclear. Never run a command you couldn&#39;t describe in plain language, even roughly.</p>

<p>The blast radius of misplaced trust is real. Understanding is what keeps it small.</p>

<hr>

<p><em>Written with assistance from Claude.</em></p>

<hr>

<p><em>Want to reach out? <a href="https://contact.pven.com">Contact me</a>.</em></p>
]]></content:encoded>
      <guid>https://unprompted.pven.com/you-dont-need-to-master-it</guid>
      <pubDate>Sun, 07 Jun 2026 18:52:05 +0000</pubDate>
    </item>
    <item>
      <title>When one AI tells you to ask another</title>
      <link>https://unprompted.pven.com/when-one-ai-tells-you-to-ask-another</link>
      <description>&lt;![CDATA[A practical story about Oracle 19c, RHEL 9, and an unexpected referral.&#xA;&#xA;---&#xA;&#xA;I was automating an Oracle 19c installation on Red Hat Enterprise Linux 9 by creating an Ansible-playbook. What followed was a small lesson in how AI tools are starting to complement each other in ways I didn&#39;t expect.&#xA;&#xA;The problem: base release, wrong era&#xA;&#xA;When you download Oracle 19c, you get version 19.3, the original base release. The catch: Oracle 19.3 was never certified to run on RHEL 9. Oracle 19.3 shipped before RHEL 9 existed. The installer doesn&#39;t refuse outright, but things quietly break in ways that are hard to trace from first principles.&#xA;&#xA;My Ansible-playbook kept failing. I adjusted prerequisites, kernel parameters, user limits. Nothing worked. The errors were vague, the Oracle docs were circular, and the usual sysadmin intuition wasn&#39;t cutting it.&#xA;&#xA;What Claude did next&#xA;&#xA;After several iterations, I was using Claude to help debug the playbook. At some point, after the obvious fixes were exhausted, it made a suggestion I hadn&#39;t expected.&#xA;&#xA;It recognized that the root cause was likely a certification gap between Oracle 19.3 and RHEL 9, and that Oracle Support&#39;s AI assistant has direct access to the official patch matrix. It handed me a ready-made prompt to use:&#xA;&#xA;We are trying to install Oracle Database 19c (19.3 base installer) with RU 19.31 (patch 39034528) on RHEL 9 x86-64 using a silent installation (no GUI available). The silent installer consistently fails with &#34;undefined reference to stat&#34; in libnnzst19.a(ccmeckrandloadfileS1.o) during the linking of the orion binary (make target allnoorcl). Because the installer fails at this point, the oraInventory is never created, which means opatch apply and runInstaller -applyRU both fail as they require a registered inventory to proceed.&#xA;MOS Note 3064394.1 describes a workaround involving copying files from patch 33794281 into ORACLEHOME/lib/stubs and clicking Retry in the GUI installer. We cannot use this workaround because we have no GUI.&#xA;We have already tried CVASSUMEDISTID=OL7/OL8/RHEL8/RHEL7.8, -ignoreInternalDriverError, libpthreadnonshared.a symlink, and manually creating the oraInventory XML.&#xA;&#xA;Question: How do we apply the lib/stubs fix from patch 33794281 during a silent installation on RHEL 9 x86-64 with RU 19.31, so that the linking errors are resolved and the oraInventory is created successfully?&#xA;So I did. I opened Oracle Support, pasted that prompt into their AI assistant, and within seconds got a concrete answer: a specific patch number that brings 19.3 to a version certified on RHEL 9.&#xA;&#xA;Patch applied, playbook fixed&#xA;&#xA;I added the patch steps to the playbook and ran it again. The installation completed cleanly. What had been a multi-day friction point resolved in minutes, not because I found the answer, but because I was pointed to where it lived.&#xA;&#xA;  Claude didn&#39;t know the Oracle patch number. But it knew who did, and it knew how to ask the right question to get there.&#xA;&#xA;The actual takeaway&#xA;&#xA;This wasn&#39;t magic. It was a routing decision. Claude recognized the boundary of its own knowledge and pointed toward a system (Oracle&#39;s support AI) that has access to proprietary, continuously updated patch data. The value wasn&#39;t in the answer; it was in the referral and the framing of the question.&#xA;&#xA;It&#39;s a pattern worth internalizing: AI tools aren&#39;t monolithic. Some have broad general knowledge; others have deep, domain-specific, up-to-date access. Knowing which to ask, and how, is increasingly part of the job.&#xA;&#xA;In this case, one AI sent me to another. The second one had what the first one didn&#39;t. The playbook works.&#xA;&#xA;---&#xD;&#xA;Want to reach out? Contact me.]]&gt;</description>
      <content:encoded><![CDATA[<p><em>A practical story about Oracle 19c, RHEL 9, and an unexpected referral.</em></p>

<hr>

<p>I was automating an Oracle 19c installation on Red Hat Enterprise Linux 9 by creating an Ansible-playbook. What followed was a small lesson in how AI tools are starting to complement each other in ways I didn&#39;t expect.</p>

<h2 id="the-problem-base-release-wrong-era">The problem: base release, wrong era</h2>

<p>When you download Oracle 19c, you get version 19.3, the original base release. The catch: Oracle 19.3 was never certified to run on RHEL 9. Oracle 19.3 shipped before RHEL 9 existed. The installer doesn&#39;t refuse outright, but things quietly break in ways that are hard to trace from first principles.</p>

<p>My Ansible-playbook kept failing. I adjusted prerequisites, kernel parameters, user limits. Nothing worked. The errors were vague, the Oracle docs were circular, and the usual sysadmin intuition wasn&#39;t cutting it.</p>

<h2 id="what-claude-did-next">What Claude did next</h2>

<p>After several iterations, I was using Claude to help debug the playbook. At some point, after the obvious fixes were exhausted, it made a suggestion I hadn&#39;t expected.</p>

<p>It recognized that the root cause was likely a certification gap between Oracle 19.3 and RHEL 9, and that Oracle Support&#39;s AI assistant has direct access to the official patch matrix. It handed me a ready-made prompt to use:</p>

<pre><code>We are trying to install Oracle Database 19c (19.3 base installer) with RU 19.31 (patch 39034528) on RHEL 9 x86-64 using a silent installation (no GUI available). The silent installer consistently fails with &#34;undefined reference to stat&#34; in libnnzst19.a(ccme_ck_rand_load_fileS1.o) during the linking of the orion binary (make target all_no_orcl). Because the installer fails at this point, the oraInventory is never created, which means opatch apply and runInstaller -applyRU both fail as they require a registered inventory to proceed.
MOS Note 3064394.1 describes a workaround involving copying files from patch 33794281 into ORACLE_HOME/lib/stubs and clicking Retry in the GUI installer. We cannot use this workaround because we have no GUI.
We have already tried CV_ASSUME_DISTID=OL7/OL8/RHEL8/RHEL7.8, -ignoreInternalDriverError, libpthread_nonshared.a symlink, and manually creating the oraInventory XML.

Question: How do we apply the lib/stubs fix from patch 33794281 during a silent installation on RHEL 9 x86-64 with RU 19.31, so that the linking errors are resolved and the oraInventory is created successfully?
</code></pre>

<p>So I did. I opened Oracle Support, pasted that prompt into their AI assistant, and within seconds got a concrete answer: a specific patch number that brings 19.3 to a version certified on RHEL 9.</p>

<h2 id="patch-applied-playbook-fixed">Patch applied, playbook fixed</h2>

<p>I added the patch steps to the playbook and ran it again. The installation completed cleanly. What had been a multi-day friction point resolved in minutes, not because I found the answer, but because I was pointed to where it lived.</p>

<blockquote><p>Claude didn&#39;t know the Oracle patch number. But it knew who did, and it knew how to ask the right question to get there.</p></blockquote>

<h2 id="the-actual-takeaway">The actual takeaway</h2>

<p>This wasn&#39;t magic. It was a routing decision. Claude recognized the boundary of its own knowledge and pointed toward a system (Oracle&#39;s support AI) that has access to proprietary, continuously updated patch data. The value wasn&#39;t in the answer; it was in the referral and the framing of the question.</p>

<p>It&#39;s a pattern worth internalizing: AI tools aren&#39;t monolithic. Some have broad general knowledge; others have deep, domain-specific, up-to-date access. Knowing which to ask, and how, is increasingly part of the job.</p>

<p>In this case, one AI sent me to another. The second one had what the first one didn&#39;t. The playbook works.</p>

<hr>

<p><em>Want to reach out? <a href="https://contact.pven.com">Contact me</a>.</em></p>
]]></content:encoded>
      <guid>https://unprompted.pven.com/when-one-ai-tells-you-to-ask-another</guid>
      <pubDate>Fri, 29 May 2026 20:58:42 +0000</pubDate>
    </item>
    <item>
      <title>From frustration to automation: managing Marktplaats listings with Claude</title>
      <link>https://unprompted.pven.com/from-frustration-to-automation-managing-marktplaats-listings-with-claude</link>
      <description>&lt;![CDATA[I sell things on Marktplaats. Not professionally, just the usual household clutter that accumulates faster than you&#39;d think. Over time I built up a small collection of items I cycle through: things I list, sell, and sometimes re-list when they don&#39;t move.&#xA;&#xA;The problem with Marktplaats is that listings age. They sink to the bottom of search results over time, so if something doesn&#39;t sell quickly, you need to re-list it. That means logging in, filling out the same forms again, uploading the same photos, writing the same description. Every time.&#xA;&#xA;At some point I decided that was a problem worth solving.&#xA;&#xA;Phase one: a script on my laptop&#xA;&#xA;The first version was a Python script I built together with Claude. It reads a simple textfile describing the listing: title, category, price, delivery options, description, photos, and handles the rest automatically. One command, done.&#xA;&#xA;The textfile approach turned out to be surprisingly pleasant. Each listing lives in its own file, easy to tweak, easy to version. The script became my actual workflow: edit the file, run the script, walk away.&#xA;&#xA;That worked well for a while. But running a script from a terminal still felt like more friction than necessary, especially when I just wanted to quickly re-list something that had slipped off the first page.&#xA;&#xA;Phase two: moving it to the server&#xA;&#xA;My homelab runs a small server. It already hosts a bunch of Docker containers: media, home automation, monitoring, that kind of stuff. So I asked Claude: what would it take to wrap this in a proper web interface, so I can manage listings from my browser?&#xA;&#xA;The answer turned out to be: not that much.&#xA;&#xA;We built a small web app that runs on my server. It gives me:&#xA;A proper editor for my listing files, with syntax highlighting&#xA;Photo management with drag-and-drop upload and preview thumbnails&#xA;A run button that fires off the automation and streams the output live to the browser, so I can watch it work&#xA;An overview of all listings, with the last time each one was placed, and the ability to select and run multiple at once&#xA;&#xA;The whole thing sits behind my reverse proxy, accessible only from my own network. No cloud, no subscription, no third-party service involved.&#xA;&#xA;What Claude actually did&#xA;&#xA;Most of it, honestly. I described what I wanted, pushed back when something felt overcomplicated, and redirected when we went off course. Claude wrote the application logic, the streaming output, the Docker setup, and adapted the original script to run on Linux instead of on my Windows laptop.&#xA;&#xA;What I contributed was knowing what I wanted, catching things that didn&#39;t feel right, and doing the actual testing. The kind of collaboration where you&#39;re the product manager and the AI is a very fast, very patient developer who doesn&#39;t complain about changing requirements.&#xA;&#xA;The result is something I&#39;d never have built on my own. Not because it&#39;s technically beyond me, but because I&#39;d have talked myself out of the effort before starting. With Claude, the activation energy is low enough that &#34;wouldn&#39;t it be nice if...&#34; actually becomes a working thing.&#xA;&#xA;The disclaimer&#xA;&#xA;I&#39;m deliberately vague about how the automation works under the hood. Platforms don&#39;t love bots, even well-behaved personal ones. This post is about the journey, not a how-to.&#xA;&#xA;But the broader point stands: if you have a repetitive task that involves a website, and you&#39;ve got a Claude subscription, it&#39;s probably worth asking whether it could be automated. And if it&#39;s already automated, it&#39;s probably worth asking whether it could be a proper tool.&#xA;&#xA;---&#xA;Written with assistance from Claude.&#xA;&#xA;---&#xD;&#xA;Want to reach out? Contact me.]]&gt;</description>
      <content:encoded><![CDATA[<p>I sell things on Marktplaats. Not professionally, just the usual household clutter that accumulates faster than you&#39;d think. Over time I built up a small collection of items I cycle through: things I list, sell, and sometimes re-list when they don&#39;t move.</p>

<p>The problem with Marktplaats is that listings age. They sink to the bottom of search results over time, so if something doesn&#39;t sell quickly, you need to re-list it. That means logging in, filling out the same forms again, uploading the same photos, writing the same description. Every time.</p>

<p>At some point I decided that was a problem worth solving.</p>

<h2 id="phase-one-a-script-on-my-laptop">Phase one: a script on my laptop</h2>

<p>The first version was a Python script I built together with Claude. It reads a simple textfile describing the listing: title, category, price, delivery options, description, photos, and handles the rest automatically. One command, done.</p>

<p>The textfile approach turned out to be surprisingly pleasant. Each listing lives in its own file, easy to tweak, easy to version. The script became my actual workflow: edit the file, run the script, walk away.</p>

<p>That worked well for a while. But running a script from a terminal still felt like more friction than necessary, especially when I just wanted to quickly re-list something that had slipped off the first page.</p>

<h2 id="phase-two-moving-it-to-the-server">Phase two: moving it to the server</h2>

<p>My homelab runs a small server. It already hosts a bunch of Docker containers: media, home automation, monitoring, that kind of stuff. So I asked Claude: what would it take to wrap this in a proper web interface, so I can manage listings from my browser?</p>

<p>The answer turned out to be: not that much.</p>

<p>We built a small web app that runs on my server. It gives me:
– A proper editor for my listing files, with syntax highlighting
– Photo management with drag-and-drop upload and preview thumbnails
– A run button that fires off the automation and streams the output live to the browser, so I can watch it work
– An overview of all listings, with the last time each one was placed, and the ability to select and run multiple at once</p>

<p>The whole thing sits behind my reverse proxy, accessible only from my own network. No cloud, no subscription, no third-party service involved.</p>

<h2 id="what-claude-actually-did">What Claude actually did</h2>

<p>Most of it, honestly. I described what I wanted, pushed back when something felt overcomplicated, and redirected when we went off course. Claude wrote the application logic, the streaming output, the Docker setup, and adapted the original script to run on Linux instead of on my Windows laptop.</p>

<p>What I contributed was knowing what I wanted, catching things that didn&#39;t feel right, and doing the actual testing. The kind of collaboration where you&#39;re the product manager and the AI is a very fast, very patient developer who doesn&#39;t complain about changing requirements.</p>

<p>The result is something I&#39;d never have built on my own. Not because it&#39;s technically beyond me, but because I&#39;d have talked myself out of the effort before starting. With Claude, the activation energy is low enough that “wouldn&#39;t it be nice if...” actually becomes a working thing.</p>

<h2 id="the-disclaimer">The disclaimer</h2>

<p>I&#39;m deliberately vague about how the automation works under the hood. Platforms don&#39;t love bots, even well-behaved personal ones. This post is about the journey, not a how-to.</p>

<p>But the broader point stands: if you have a repetitive task that involves a website, and you&#39;ve got a Claude subscription, it&#39;s probably worth asking whether it could be automated. And if it&#39;s already automated, it&#39;s probably worth asking whether it could be a proper tool.</p>

<hr>

<p><em>Written with assistance from Claude.</em></p>

<hr>

<p><em>Want to reach out? <a href="https://contact.pven.com">Contact me</a>.</em></p>
]]></content:encoded>
      <guid>https://unprompted.pven.com/from-frustration-to-automation-managing-marktplaats-listings-with-claude</guid>
      <pubDate>Mon, 25 May 2026 17:17:00 +0000</pubDate>
    </item>
    <item>
      <title>Monitoring Claude Pro Usage with a Bash Script</title>
      <link>https://unprompted.pven.com/monitoring-claude-pro-usage-with-a-bash-script</link>
      <description>&lt;![CDATA[If you run Claude Code heavily, you&#39;ve probably stared at the usage bar wondering how much of your 5-hour block is gone — and whether your extra credits are quietly draining. I built a small bash script to stop guessing and start logging.&#xA;&#xA;The problem&#xA;&#xA;Claude Pro has three usage dimensions worth tracking:&#xA;&#xA;5-hour block utilisation — the rolling window that gates your active sessions&#xA;7-day utilisation — the broader weekly cap&#xA;Extra credits — the paid overflow, billed in euros&#xA;&#xA;None of this is exposed in a machine-readable way by default. It turns out there is an undocumented OAuth endpoint that exposes exactly that data.&#xA;&#xA;The endpoint&#xA;&#xA;Claude Code stores an OAuth access token in ~/.claude/.credentials.json. The same token works against the usage API:&#xA;&#xA;GET https://api.anthropic.com/api/oauth/usage&#xA;Authorization: Bearer token&#xA;anthropic-beta: oauth-2025-04-20&#xA;&#xA;The response looks like this:&#xA;&#xA;{&#xA;  &#34;fivehour&#34;: { &#34;utilization&#34;: 7.0, &#34;resetsat&#34;: &#34;...&#34; },&#xA;  &#34;sevenday&#34;: { &#34;utilization&#34;: 19.0, &#34;resetsat&#34;: &#34;...&#34; },&#xA;  &#34;extrausage&#34;: {&#xA;    &#34;isenabled&#34;: true,&#xA;    &#34;monthlylimit&#34;: 1700,&#xA;    &#34;usedcredits&#34;: 190.0,&#xA;    &#34;utilization&#34;: 11.18,&#xA;    &#34;currency&#34;: &#34;EUR&#34;&#xA;  }&#xA;}&#xA;&#xA;Note: monthlylimit and usedcredits are in eurocents. Divide by 100 for the actual euro amounts.&#xA;&#xA;The script&#xA;&#xA;The script reads the token, hits the endpoint, optionally queries ccusage blocks for remaining block time, and appends a single JSON line to a log file — ready for Loki to scrape and Grafana to visualise.&#xA;&#xA;!/usr/bin/env bash&#xA;Dependencies: jq, curl, ccusage (npm install -g ccusage)&#xA;&#xA;LOGFILE=&#34;/var/log/ccusage.log&#34;&#xA;CREDENTIALSFILE=&#34;${HOME}/.claude/.credentials.json&#34;&#xA;ANTHROPICUSAGEURL=&#34;https://api.anthropic.com/api/oauth/usage&#34;&#xA;&#xA;log() { echo &#34;[$(date &#39;+%d-%b-%Y %H:%M:%S&#39;)] $&#34;   &amp;2; }&#xA;&#xA;TOKEN=$(jq -r &#39;.claudeAiOauth.accessToken // empty&#39; &#34;$CREDENTIALSFILE&#34; 2  /dev/null)&#xA;if [[ -z &#34;$TOKEN&#34; ]]; then&#xA;    log &#34;No credentials available, aborting&#34;&#xA;    exit 1&#xA;fi&#xA;&#xA;USAGE=$(curl -s \&#xA;    -H &#34;Authorization: Bearer $TOKEN&#34; \&#xA;    -H &#34;anthropic-beta: oauth-2025-04-20&#34; \&#xA;    &#34;$ANTHROPICUSAGEURL&#34;)&#xA;&#xA;if ! echo &#34;$USAGE&#34; | jq -e &#39;.fivehour&#39;   /dev/null 2  &amp;1; then&#xA;    log &#34;Invalid API response&#34;&#xA;    exit 1&#xA;fi&#xA;&#xA;BLOK=$(echo &#34;$USAGE&#34;         | jq &#39;(.fivehour.utilization   // 0) | round&#39;)&#xA;WEEK=$(echo &#34;$USAGE&#34;         | jq &#39;(.sevenday.utilization   // 0) | round&#39;)&#xA;CREDITSPCT=$(echo &#34;$USAGE&#34;  | jq &#39;(.extrausage.utilization // 0)  100 | round / 100&#39;)&#xA;CREDITSUSED=$(echo &#34;$USAGE&#34; | jq &#39;(.extrausage.usedcredits  // 0) / 100&#39;)&#xA;CREDITSMAX=$(echo &#34;$USAGE&#34;  | jq &#39;(.extrausage.monthlylimit // 0) / 100&#39;)&#xA;&#xA;REMAINING=$(ccusage blocks --json 2  /dev/null | jq &#39;&#xA;    if (.blocks // []) | length   0&#xA;       and (.blocks[-1].projection.remainingMinutes // null) != null&#xA;    then (.blocks[-1].projection.remainingMinutes / 60  10 | round / 10)&#xA;    else 0&#xA;    end&#39; 2  /dev/null || echo 0)&#xA;&#xA;printf &#39;{&#34;timestamp&#34;:&#34;%s&#34;,&#34;blokpct&#34;:%s,&#34;weekpct&#34;:%s,&#34;creditspct&#34;:%s,&#34;creditsused&#34;:%s,&#34;creditsmax&#34;:%s,&#34;blokremaininghours&#34;:%s}\n&#39; \&#xA;    &#34;$(date -u &#39;+%Y-%m-%dT%H:%M:%SZ&#39;)&#34; \&#xA;    &#34;$BLOK&#34; &#34;$WEEK&#34; &#34;$CREDITSPCT&#34; &#34;$CREDITSUSED&#34; &#34;$CREDITSMAX&#34; &#34;$REMAINING&#34; \&#xA;        &#34;$LOGFILE&#34;&#xA;&#xA;log &#34;ccusage logged: block=${BLOK}% week=${WEEK}% credits=${CREDITSPCT}%&#34;&#xA;&#xA;Install&#xA;&#xA;Dependencies:&#xA;&#xA;apt install jq curl&#xA;npm install -g ccusage&#xA;&#xA;Deploy:&#xA;&#xA;cp ccusagelog.sh /usr/local/sbin/ccusagelog&#xA;chmod +x /usr/local/sbin/ccusagelog&#xA;&#xA;Cron — every 5 minutes:&#xA;&#xA;/5     /usr/local/sbin/ccusagelog&#xA;&#xA;Output&#xA;&#xA;Each run appends one line to /var/log/ccusage.log:&#xA;&#xA;{&#34;timestamp&#34;:&#34;2026-05-23T19:26:17Z&#34;,&#34;blokpct&#34;:7,&#34;weekpct&#34;:19,&#34;creditspct&#34;:11.18,&#34;creditsused&#34;:1.9,&#34;creditsmax&#34;:17,&#34;blokremaininghours&#34;:0}&#xA;&#xA;Point a Loki scrape job at that file and you have time-series data for all six metrics. Setting up Loki and Grafana for this is a topic for a separate post.&#xA;&#xA;Notes&#xA;&#xA;Tested on Claude Pro with Claude Code. May work with other Claude subscriptions, but I haven&#39;t verified.&#xA;The OAuth endpoint is undocumented and could change without notice.&#xA;blokremaininghours is 0 when no active session block is running — that&#39;s expected.&#xA;The full script with the latest version is available on GitHub: pven/scripts/claude&#xA;&#xA;---&#xA;Written with assistance from Claude.&#xA;&#xA;---&#xD;&#xA;Want to reach out? Contact me._]]&gt;</description>
      <content:encoded><![CDATA[<p>If you run Claude Code heavily, you&#39;ve probably stared at the usage bar wondering how much of your 5-hour block is gone — and whether your extra credits are quietly draining. I built a small bash script to stop guessing and start logging.</p>

<h2 id="the-problem">The problem</h2>

<p>Claude Pro has three usage dimensions worth tracking:</p>
<ul><li><strong>5-hour block utilisation</strong> — the rolling window that gates your active sessions</li>
<li><strong>7-day utilisation</strong> — the broader weekly cap</li>
<li><strong>Extra credits</strong> — the paid overflow, billed in euros</li></ul>

<p>None of this is exposed in a machine-readable way by default. It turns out there is an undocumented OAuth endpoint that exposes exactly that data.</p>

<h2 id="the-endpoint">The endpoint</h2>

<p>Claude Code stores an OAuth access token in <code>~/.claude/.credentials.json</code>. The same token works against the usage API:</p>

<pre><code>GET https://api.anthropic.com/api/oauth/usage
Authorization: Bearer &lt;token&gt;
anthropic-beta: oauth-2025-04-20
</code></pre>

<p>The response looks like this:</p>

<pre><code class="language-json">{
  &#34;five_hour&#34;: { &#34;utilization&#34;: 7.0, &#34;resets_at&#34;: &#34;...&#34; },
  &#34;seven_day&#34;: { &#34;utilization&#34;: 19.0, &#34;resets_at&#34;: &#34;...&#34; },
  &#34;extra_usage&#34;: {
    &#34;is_enabled&#34;: true,
    &#34;monthly_limit&#34;: 1700,
    &#34;used_credits&#34;: 190.0,
    &#34;utilization&#34;: 11.18,
    &#34;currency&#34;: &#34;EUR&#34;
  }
}
</code></pre>

<p>Note: <code>monthly_limit</code> and <code>used_credits</code> are in eurocents. Divide by 100 for the actual euro amounts.</p>

<h2 id="the-script">The script</h2>

<p>The script reads the token, hits the endpoint, optionally queries <code>ccusage blocks</code> for remaining block time, and appends a single JSON line to a log file — ready for Loki to scrape and Grafana to visualise.</p>

<pre><code class="language-bash">#!/usr/bin/env bash
# Dependencies: jq, curl, ccusage (npm install -g ccusage)

LOGFILE=&#34;/var/log/ccusage.log&#34;
CREDENTIALS_FILE=&#34;${HOME}/.claude/.credentials.json&#34;
ANTHROPIC_USAGE_URL=&#34;https://api.anthropic.com/api/oauth/usage&#34;

log() { echo &#34;[$(date &#39;+%d-%b-%Y %H:%M:%S&#39;)] $*&#34; &gt;&amp;2; }

TOKEN=$(jq -r &#39;.claudeAiOauth.accessToken // empty&#39; &#34;$CREDENTIALS_FILE&#34; 2&gt;/dev/null)
if [[ -z &#34;$TOKEN&#34; ]]; then
    log &#34;No credentials available, aborting&#34;
    exit 1
fi

USAGE=$(curl -s \
    -H &#34;Authorization: Bearer $TOKEN&#34; \
    -H &#34;anthropic-beta: oauth-2025-04-20&#34; \
    &#34;$ANTHROPIC_USAGE_URL&#34;)

if ! echo &#34;$USAGE&#34; | jq -e &#39;.five_hour&#39; &gt; /dev/null 2&gt;&amp;1; then
    log &#34;Invalid API response&#34;
    exit 1
fi

BLOK=$(echo &#34;$USAGE&#34;         | jq &#39;(.five_hour.utilization   // 0) | round&#39;)
WEEK=$(echo &#34;$USAGE&#34;         | jq &#39;(.seven_day.utilization   // 0) | round&#39;)
CREDITS_PCT=$(echo &#34;$USAGE&#34;  | jq &#39;(.extra_usage.utilization // 0) * 100 | round / 100&#39;)
CREDITS_USED=$(echo &#34;$USAGE&#34; | jq &#39;(.extra_usage.used_credits  // 0) / 100&#39;)
CREDITS_MAX=$(echo &#34;$USAGE&#34;  | jq &#39;(.extra_usage.monthly_limit // 0) / 100&#39;)

REMAINING=$(ccusage blocks --json 2&gt;/dev/null | jq &#39;
    if (.blocks // []) | length &gt; 0
       and (.blocks[-1].projection.remainingMinutes // null) != null
    then (.blocks[-1].projection.remainingMinutes / 60 * 10 | round / 10)
    else 0
    end&#39; 2&gt;/dev/null || echo 0)

printf &#39;{&#34;timestamp&#34;:&#34;%s&#34;,&#34;blok_pct&#34;:%s,&#34;week_pct&#34;:%s,&#34;credits_pct&#34;:%s,&#34;credits_used&#34;:%s,&#34;credits_max&#34;:%s,&#34;blok_remaining_hours&#34;:%s}\n&#39; \
    &#34;$(date -u &#39;+%Y-%m-%dT%H:%M:%SZ&#39;)&#34; \
    &#34;$BLOK&#34; &#34;$WEEK&#34; &#34;$CREDITS_PCT&#34; &#34;$CREDITS_USED&#34; &#34;$CREDITS_MAX&#34; &#34;$REMAINING&#34; \
    &gt;&gt; &#34;$LOGFILE&#34;

log &#34;ccusage logged: block=${BLOK}% week=${WEEK}% credits=${CREDITS_PCT}%&#34;
</code></pre>

<h2 id="install">Install</h2>

<p><strong>Dependencies:</strong></p>

<pre><code class="language-bash">apt install jq curl
npm install -g ccusage
</code></pre>

<p><strong>Deploy:</strong></p>

<pre><code class="language-bash">cp ccusage_log.sh /usr/local/sbin/ccusage_log
chmod +x /usr/local/sbin/ccusage_log
</code></pre>

<p><strong>Cron — every 5 minutes:</strong></p>

<pre><code class="language-bash">*/5 * * * * /usr/local/sbin/ccusage_log
</code></pre>

<h2 id="output">Output</h2>

<p>Each run appends one line to <code>/var/log/ccusage.log</code>:</p>

<pre><code class="language-json">{&#34;timestamp&#34;:&#34;2026-05-23T19:26:17Z&#34;,&#34;blok_pct&#34;:7,&#34;week_pct&#34;:19,&#34;credits_pct&#34;:11.18,&#34;credits_used&#34;:1.9,&#34;credits_max&#34;:17,&#34;blok_remaining_hours&#34;:0}
</code></pre>

<p>Point a Loki scrape job at that file and you have time-series data for all six metrics. Setting up Loki and Grafana for this is a topic for a separate post.</p>

<h2 id="notes">Notes</h2>
<ul><li>Tested on Claude Pro with Claude Code. May work with other Claude subscriptions, but I haven&#39;t verified.</li>
<li>The OAuth endpoint is undocumented and could change without notice.</li>
<li><code>blok_remaining_hours</code> is <code>0</code> when no active session block is running — that&#39;s expected.</li>
<li>The full script with the latest version is available on GitHub: <a href="https://github.com/pven/scripts/tree/main/claude">pven/scripts/claude</a></li></ul>

<hr>

<p><em>Written with assistance from Claude.</em></p>

<hr>

<p><em>Want to reach out? <a href="https://contact.pven.com">Contact me</a>.</em></p>
]]></content:encoded>
      <guid>https://unprompted.pven.com/monitoring-claude-pro-usage-with-a-bash-script</guid>
      <pubDate>Sat, 23 May 2026 17:39:44 +0000</pubDate>
    </item>
  </channel>
</rss>