<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://www.thomas-witt.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://www.thomas-witt.com/" rel="alternate" type="text/html" /><updated>2026-08-20T10:05:19+00:00</updated><id>https://www.thomas-witt.com/feed.xml</id><title type="html">Thomas Witt: Tech Entrepreneur &amp;amp; Angel Investor</title><subtitle>Tech entrepreneur, zero-to-one SaaS founder, and angel investor. Co-founder of Expedite Ventures, investing in deep-tech startups.</subtitle><author><name>Thomas Witt</name></author><entry><title type="html">How I stopped my Claude Code subagents from secretly running on Fable instead of Sonnet</title><link href="https://www.thomas-witt.com/blog/blog-subagent-model-pin/" rel="alternate" type="text/html" title="How I stopped my Claude Code subagents from secretly running on Fable instead of Sonnet" /><published>2026-08-15T00:00:00+00:00</published><updated>2026-08-15T12:10:02+00:00</updated><id>https://www.thomas-witt.com/blog/blog-subagent-model-pin</id><content type="html" xml:base="https://www.thomas-witt.com/blog/blog-subagent-model-pin/"><![CDATA[<p>I run Claude Code with a big session model, Fable or Opus 5, plus a small zoo of subagents doing the boring parts. Gateway agents, formatters, checkers. The kind of mechanical stuff you pin to a small model once, in the agent’s frontmatter, and then never think about again.</p>

<p>At some point the token consumption stopped matching my gut feeling of what I’d actually been doing that week. Nothing was broken. Nothing errored. Everything worked. It just cost more than it should have.</p>

<p>So I decided to take a deeper look at how Claude Code actually picks the model for a subagent. It turned out the pin I’d been trusting sits in the one layer you shouldn’t trust.</p>

<blockquote>
  <p><strong>Disclaimer:</strong> This is what I run on my own machine, on my own projects. Hooks can block your own dispatches, that’s the whole point of this one, so if you wire it up wrong you’ll be staring at a blocked Task call and wondering why. Also, precedence behaviour in Claude Code changes between releases. Verify against the version you’re on.</p>
</blockquote>

<h2 id="why-you-want-subagents-in-the-first-place">Why you want subagents in the first place</h2>

<p>Before the complaining starts, let me be clear about one thing: subagents are good. This post is not an argument against them, it’s an argument for pinning them properly.</p>

<p>The reason to use one isn’t that it’s a smaller model. It’s that it runs in its <strong>own context window</strong>. It goes off, does something loud and messy, and hands back a short answer. The noise never lands in your main session. You get the three lines that matter instead of the four megabytes they came from.</p>

<p>Which means the ideal subagent job looks like this: <em>fetch a lot, filter, return a little</em>. And that job needs a model that is obedient, not brilliant. Something like Sonnet does it all day. Running it on Fable or Opus 5 is paying frontier prices for grep with good manners.</p>

<p>My list of agents that should never touch a frontier model:</p>

<ul>
  <li><strong>AWS, especially CloudWatch Logs.</strong> A log query for one request ID returns an ocean. You want the stack trace and the timestamp. This one alone justifies the whole pattern.</li>
  <li><strong>GitHub.</strong> Issues, PR diffs, CI status, “which commit touched this file”. Long output, small answer.</li>
  <li><strong>Honeybadger.</strong> Error occurrences, backtraces, “is this the same bug as last Tuesday”. Structured input, structured output.</li>
  <li><strong>Langfuse.</strong> Trace dumps are enormous and 95% of every trace is irrelevant to the question you’re asking about it.</li>
  <li><strong>Static analysis: RubyCritic, RuboCop and friends.</strong> The report is a hundred pages, the actionable part is nine lines.</li>
  <li><strong>Test runners.</strong> You do not need Fable to look at RSpec output and tell you which four specs are red. You need something that can read.</li>
</ul>

<p>Pinning one looks like this, in <code class="language-plaintext highlighter-rouge">.claude/agents/cloudwatch-digger.md</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">---</span>
<span class="na">name</span><span class="pi">:</span> <span class="s">cloudwatch-digger</span>
<span class="na">description</span><span class="pi">:</span> <span class="s">Queries CloudWatch Logs and returns only the relevant lines</span>
<span class="na">model</span><span class="pi">:</span> <span class="s">sonnet</span>
<span class="na">tools</span><span class="pi">:</span> <span class="s">Bash, Read</span>
<span class="nn">---</span>
</code></pre></div></div>

<p>One line. <code class="language-plaintext highlighter-rouge">model: sonnet</code>. That’s the whole pin, and that’s exactly why it hurts when it silently stops working. The agents you bother to pin are, by definition, the ones you dispatch most often and look at least.</p>

<h2 id="the-first-uncomfortable-thing">The first uncomfortable thing</h2>

<p>Claude Code resolves which model a subagent runs on in this order:</p>

<table>
  <thead>
    <tr>
      <th>rank</th>
      <th>layer</th>
      <th>how you set it</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>environment variable</td>
      <td>shell / launch config</td>
    </tr>
    <tr>
      <td>2</td>
      <td>per-invocation parameter</td>
      <td><code class="language-plaintext highlighter-rouge">model</code> on the dispatch itself</td>
    </tr>
    <tr>
      <td>3</td>
      <td>agent frontmatter</td>
      <td><code class="language-plaintext highlighter-rouge">model:</code> in <code class="language-plaintext highlighter-rouge">.claude/agents/&lt;name&gt;.md</code></td>
    </tr>
    <tr>
      <td>4</td>
      <td>session model</td>
      <td>whatever you started the session with</td>
    </tr>
  </tbody>
</table>

<p>Four layers. And the one that everybody actually uses, the frontmatter pin, because it’s the one that’s documented, obvious and writable once, is rank 3 of 4.</p>

<p>That would be fine if rank 3 always held. It doesn’t. Across several releases the frontmatter layer has silently dropped out, and pinned agents fell straight through to rank 4: the session model. Which in my case is Fable.</p>

<p>So the cheap little agent you dispatch two hundred times a day quietly runs on the most expensive thing you have. And here’s the part I find genuinely annoying: there is no signal. No error, no warning, nothing in the transcript that looks different. The agent does its job. It just does it at a multiple of the price.</p>

<p>A crash is polite, it tells you. This doesn’t tell you anything. It shows up four weeks later as a number.</p>

<h2 id="recognising-the-pattern">Recognising the pattern</h2>

<p>I wasn’t the first one to run into this. There’s a whole class of upstream reports about frontmatter pins being ignored after an update, and the workaround people keep confirming is always the same: pass the model explicitly on the dispatch. That’s rank 2, one layer <em>above</em> frontmatter, and rank 2 has never been the layer that breaks.</p>

<p>Big shoutout to everyone who bothered to file those issues with reproductions. Silent cost regressions are exactly the kind of bug nobody files, because nobody notices.</p>

<p>Which leaves an obvious problem: “just always pass the model explicitly” means the orchestrator has to remember it, every single time, forever. An orchestrator that reliably remembers a thing forever is not something I’ve met.</p>

<p>So don’t remember. Enforce.</p>

<h2 id="phase-1-a-pretooluse-hook">Phase 1: A PreToolUse hook</h2>

<p><code class="language-plaintext highlighter-rouge">PreToolUse</code> runs before a tool call goes through and can block it with exit code 2. So: if a subagent is pinned in its frontmatter, and the dispatch carries no explicit <code class="language-plaintext highlighter-rouge">model</code>, refuse the dispatch and say exactly what to re-send.</p>

<p>These few lines of code can save you a lot of tokens, because they remind Claude Code to use the model you actually chose for your subagents.</p>

<p><code class="language-plaintext highlighter-rouge">.claude/hooks/enforce-subagent-model.sh</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>
<span class="o">{</span> <span class="nb">read</span> <span class="nt">-r</span> T<span class="p">;</span> <span class="nb">read</span> <span class="nt">-r</span> S<span class="p">;</span> <span class="nb">read</span> <span class="nt">-r</span> M<span class="p">;</span> <span class="o">}</span> &lt; &lt;<span class="o">(</span>jq <span class="nt">-r</span> <span class="s1">'.tool_name//"",.tool_input.subagent_type//"",.tool_input.model//""'</span><span class="o">)</span>
<span class="k">case</span> <span class="nv">$T</span> <span class="k">in </span>Task|Agent<span class="p">)</span> <span class="p">;;</span> <span class="k">*</span><span class="p">)</span> <span class="nb">exit </span>0<span class="p">;;</span> <span class="k">esac</span>
<span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$S</span><span class="s2">"</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="o">[</span> <span class="nt">-z</span> <span class="s2">"</span><span class="nv">$M</span><span class="s2">"</span> <span class="o">]</span> <span class="o">||</span> <span class="nb">exit </span>0
<span class="nv">P</span><span class="o">=</span><span class="si">$(</span><span class="nb">awk</span> <span class="s1">'{sub(/\r$/,"")} NR==1&amp;&amp;$0=="---"{f=1;next} f&amp;&amp;$0=="---"{exit} f&amp;&amp;/^model:[ \t]/{gsub(/["'</span><span class="s2">"'"</span><span class="s1">']/,"",$2);print $2;exit}'</span> <span class="se">\</span>
     <span class="s2">"</span><span class="k">${</span><span class="nv">CLAUDE_PROJECT_DIR</span><span class="k">:-</span><span class="p">.</span><span class="k">}</span><span class="s2">/.claude/agents/</span><span class="nv">$S</span><span class="s2">.md"</span> 2&gt;/dev/null<span class="si">)</span>
<span class="k">case</span> <span class="nv">$P</span> <span class="k">in</span> <span class="s2">""</span><span class="p">|</span>inherit<span class="p">)</span> <span class="nb">exit </span>0<span class="p">;;</span> <span class="k">esac</span>
<span class="nb">echo</span> <span class="s2">"BLOCKED: '</span><span class="nv">$S</span><span class="s2">' is pinned (model: </span><span class="nv">$P</span><span class="s2">) but this dispatch has no explicit 'model'. Re-dispatch with model: </span><span class="se">\"</span><span class="nv">$P</span><span class="se">\"</span><span class="s2">. A deliberate different model also passes, but it must be explicit."</span> <span class="o">&gt;</span>&amp;2
<span class="nb">exit </span>2
</code></pre></div></div>

<p>Nine lines. <code class="language-plaintext highlighter-rouge">jq</code> reads the hook payload from stdin, <code class="language-plaintext highlighter-rouge">awk</code> reads the pin out of the agent’s frontmatter, and the message on stderr goes back to Claude Code, which then re-dispatches correctly on its own.</p>

<p>Don’t forget:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>chmod +x .claude/hooks/enforce-subagent-model.sh
</code></pre></div></div>

<h2 id="phase-2-wiring-it-up">Phase 2: Wiring it up</h2>

<p>In <code class="language-plaintext highlighter-rouge">.claude/settings.json</code>:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"hooks"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"PreToolUse"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
      </span><span class="p">{</span><span class="w">
        </span><span class="nl">"matcher"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Task"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"hooks"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
          </span><span class="p">{</span><span class="w"> </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"command"</span><span class="p">,</span><span class="w"> </span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">"$CLAUDE_PROJECT_DIR/.claude/hooks/enforce-subagent-model.sh"</span><span class="w"> </span><span class="p">}</span><span class="w">
        </span><span class="p">]</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">]</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Test it by dispatching a pinned agent without a model. You should get the BLOCKED message, and the very next dispatch should carry the pin.</p>

<h2 id="what-it-lets-through-on-purpose">What it lets through, on purpose</h2>

<p>The interesting part of a guardrail is its exceptions:</p>

<ul>
  <li><strong>Any dispatch that carries an explicit model, even a different one.</strong> What I’m guarding against is <em>omission</em>, not choice. If I deliberately send a small agent to a big model, that’s a decision, and decisions are allowed. Falling into a model by accident is not.</li>
  <li><strong>Unpinned agents.</strong> <code class="language-plaintext highlighter-rouge">model: inherit</code> or no <code class="language-plaintext highlighter-rouge">model</code> key at all means inheritance is the intent. Fine, pass.</li>
  <li><strong>Built-in types</strong> (Explore, Plan, general-purpose, …). No file in <code class="language-plaintext highlighter-rouge">.claude/agents</code>, nothing pinned, nothing to enforce.</li>
  <li><strong>Anything unparseable.</strong> If <code class="language-plaintext highlighter-rouge">jq</code> can’t read the payload, all three variables come back empty and the hook exits 0.</li>
</ul>

<p>That last one is deliberate: this is a cost guardrail, not a security boundary. A hook that fails closed on a payload it doesn’t understand will eventually wedge a session at the worst possible moment, and then I’ll disable it, and then I’ll be back where I started.</p>

<p>An empty or <code class="language-plaintext highlighter-rouge">null</code> model, by the way, is <em>not</em> treated as a choice. It falls straight through to frontmatter, the exact layer under suspicion, so it gets gated like an absent one.</p>

<h2 id="one-thing-to-know">One thing to know</h2>

<p><strong>Workflow-tool internal <code class="language-plaintext highlighter-rouge">agent()</code> spawns don’t go through PreToolUse.</strong> The hook covers <code class="language-plaintext highlighter-rouge">Task</code> and <code class="language-plaintext highlighter-rouge">Agent</code> dispatches only. Anything spawned inside a workflow tool sails right past it. I cover those with prose in my reference docs instead, which is a nice way of saying they’re not covered. Know where your gate ends.</p>

<h2 id="thats-it">That’s it</h2>

<p>If you’re running Claude Code on Fable or Opus 5 and your token graph looks steeper than your week felt, check your pinned agents before you check anything else. Everything that’s supposed to be cheap is worth verifying, because the failure mode here doesn’t announce itself. It just quietly bills you.</p>

<p>And if you’re not using subagents at all yet, that’s a whole other topic, and honestly a bigger one than this post. Let me know if you’d like to read it and I’ll write it up.</p>

<p>If you find a cleaner way to close the workflow-tool gap, let me know as well!</p>]]></content><author><name>Thomas Witt</name></author><category term="tech" /><summary type="html"><![CDATA[I run Claude Code with a big session model, Fable or Opus 5, plus a small zoo of subagents doing the boring parts. Gateway agents, formatters, checkers. The kind of mechanical stuff you pin to a small model once, in the agent’s frontmatter, and then never think about again.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.thomas-witt.com/assets/images/posts/2026-08-15-claude_code_subagent_model_precedence.jpeg" /><media:content medium="image" url="https://www.thomas-witt.com/assets/images/posts/2026-08-15-claude_code_subagent_model_precedence.jpeg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Help, my dentist started coding!</title><link href="https://www.thomas-witt.com/blog/help-my-dentist-started-coding/" rel="alternate" type="text/html" title="Help, my dentist started coding!" /><published>2026-07-04T00:00:00+00:00</published><updated>2026-08-15T12:10:02+00:00</updated><id>https://www.thomas-witt.com/blog/help-my-dentist-started-coding</id><content type="html" xml:base="https://www.thomas-witt.com/blog/help-my-dentist-started-coding/"><![CDATA[<p>There is a recurring pattern in enterprise software history: technologies that initially promised dramatic productivity gains by hiding complexity eventually created large applications that became difficult or impossible to maintain.</p>

<p>So when my dentist celebrates on LinkedIn that Fable 5 got re-enabled and can’t wait to burn tokens again, I have to ask myself: is this a warning of how much broken software we will have to debug over the next decade? History is repeating itself here.</p>

<p>There is an old saying: if you give a fool a faster tool, all you get is a faster fool.</p>

<p>I see this pattern all the time. It’s not just the dentist who wants to get rid of his old patient management software. It’s CEOs of well-funded late-stage startups and private equity fund managers who suddenly decide that building a CRM is their core business. Marketing agencies start building iOS apps.</p>

<p>The main problem here is IMHO the false advertising by the frontier model providers. The perceived message is: “Everyone can code”.</p>

<p>Sure, if you have decades of experience in building software, Claude Code and Codex are massively powerful tools which can give you that 10x boost. I can confirm this for myself in my daily work.</p>

<p>But after years at the forefront of AI development, trying basically every new tool and model and testing them as thoroughly as possible, my personal conclusion is: we are not there.</p>

<p>Yes, AI can easily build over 90% of your code. That’s exactly what we are doing at <a href="https://vendis.ai">Vendis.ai</a> while building our AI-first CRM. BUT: the amount of internal tooling needed to steer these models in the right direction (Rules, Subagents, Skills, Configuration, Orchestration, etc.), plus the amount of manual review required to stop the coding agent from producing simply terrible software, is still remarkable. And that’s despite using a very well-organized, opinionated framework: Ruby on Rails. Every developer at Vendis has 25+ years of coding experience, and right now, that experience is simply not replaceable.</p>

<p>Will we get there at some point, through AGI or simply incrementally improved models? Very likely. As usual, people tend to overestimate the short-term effects of technology and underestimate the long-term effects.</p>

<p>The problem isn’t just the terribly unmaintainable, untested code (my prediction: software agencies and service providers will have a blast cleaning up this mess over the next years). It’s also security. Under EU law at least, you have to report data breaches and hacking attacks to the authorities. Let’s see what happens when these builders have to do that for the first time, because some Russian or North Korea ransomware group knew better how to turn frontier models into money.</p>

<p>At least it’s maybe just a question of time when your patient records will be
available on the internet, because access controls have been only implemented
client-side (<a href="https://bobdahacker.com/blog/fifa-hack">FIFA, anyone?</a>)</p>

<p>The even bigger problem is Day 2: operations. Ask these people how they actually run their software, and they just follow whatever Claude tells them. “Here is your deployment to Vercel.” Done.</p>

<p>Obviously, this results in a nightmare, from simple things like database backups all the way to security bugs. If you are lucky, Claude MIGHT tell you when to upgrade that CVE’d npm package. Or not. Or there is a privilege escalation hiding in the code. Or there are simply no tests.</p>

<p>Can a frontier model find these issues? Likely. But if you don’t ask the right questions, it won’t.</p>

<p>Interestingly, all of this reminds me of how I started my life in software, with my first company, founded in the late 90s, doing B2B software. Every potential customer we visited had proprietary Lotus Notes apps. Lotus Notes was the n8n/Lovable of the 80s and 90s. Most people today won’t remember, but I do, vividly. Lotus Notes was revolutionary. It combined databases, forms, workflows, replication, and email in one product. Business users could build applications with surprisingly little programming. It was a low-code dream that slowly but surely turned into an IT nightmare.</p>

<p>Those NSF databases were completely proprietary and mixed Formula language, LotusScript, and Java. Business logic often lived inside forms. Nothing was tested. There was very little architectural separation. And developers frequently left without documentation.</p>

<p>Employees with almost no coding experience created these apps, and the apps became centerpieces of the business. But they were usually totally unmaintainable, had a completely proprietary data model, were compatible only with themselves, and were very, very hard to modernize. Many Fortune 500 companies still run Notes applications written 20 to 30 years ago because replacing them is too risky.</p>

<p>While companies realized their legacy problem and paid literally millions of dollars to get rid of that Lotus Notes nonsense, the next hail-mary options were already appearing on the horizon: Visual Basic, PowerBuilder (still popular in many banks), ActiveX, Java applets, Silverlight, and Flash. Anyone? Or MS Access, Oracle Forms, Delphi, and the like.</p>

<p>Those technologies made sure that software consultants didn’t run out of work for the next 15 years, cleaning up this mess. If <a href="https://web.archive.org/web/20100501010616/http://www.apple.com/hotnews/thoughts-on-flash/">Steve Jobs hadn’t rejected Flash for iOS</a>, I am sure we would still have to deal with that nonsense today (the price: thousands of applications required complete rewrites). Same for ActiveX: many corporate intranets depended entirely on it until browsers killed support, even Microsoft themselves.</p>

<p>And while they were still cleaning up, the next wave was already rolling. This time, everything would be so much easier: SharePoint customizations were the rising stars, with custom Web Parts, workflows, and InfoPath forms. XML everywhere, with hidden business rules. What can go wrong? The result: yet another unmaintainable data silo, and each SharePoint upgrade became a migration project. Organizations still struggle to migrate legacy InfoPath forms today.</p>

<p>These technologies often shared the same traits:</p>

<ul>
  <li>They optimized for rapid initial delivery rather than long-term evolution.</li>
  <li>They blurred the boundaries between UI, business logic, data access, and workflow.</li>
  <li>They made automated testing and continuous integration difficult.</li>
  <li>They encouraged drag-and-drop development or code generation, producing artifacts that were hard to review and refactor.</li>
  <li>They embedded business rules in visual designers, event handlers, or metadata instead of explicit, version-controlled code.</li>
</ul>

<p>Today, we see those patterns resurface with exactly the same problems. Those 800-node n8n workflows, putting the whole company to autopilot, built by the company champion “who really gets AI”. Wait until that person leaves.</p>

<p>AI-generated code without review takes this to another level. Same pattern: fast initial velocity, but inconsistent logic and architecture, and gigantic maintenance costs.</p>

<p>Small businesses celebrate what they can build with a $200 Claude subscription, only to amass legacy software that will cost tens or hundreds of thousands of dollars to clean up.</p>

<p>And the startup CEO who dedicates two developers to building a CRM (see what happens when they leave) is basically betting at least $300k in developer salaries against a $100k CRM subscription.</p>

<p>That’s why I think BTW that SaaS standard software isn’t dead, as long as you are a system of record. Or in AI word: System of action as we call it at Vendis.ai.</p>

<p>While I can totally understand the fascination, but if you decide to build a core system (CRM, CMS, ERP, HR software, etc.) yourself, in 99.9% of all cases you are making a terrible mistake.</p>

<p>The underlying lesson has remained consistent for decades: the technologies that produce the most technical debt are rarely the ones that are “bad”. They are usually the ones that make it exceptionally easy to create software before there is enough discipline around architecture, testing, modularity, and ownership. Their productivity gains are real, but they often defer complexity rather than eliminate it.</p>

<p>Building a prototype to show your developers or consultants how it should feel like? Customizing standard software to your needs through new interfaces like MCP? Building a quick internal integration for a data migration between two legacy systems? Sure thing, that’s where Claude really shines.</p>

<p>But building, deploying, and maintaining a secure and stable core application without any clue about software development and architecture? I am not so sure, at least for the next few years.</p>

<p>Better to ask somebody who knows how to do that. Or just pay that SaaS company.</p>

<p>Just like I don’t ask ChatGPT how to drill and repair my teeth.</p>]]></content><author><name>Thomas Witt</name></author><category term="tech" /><summary type="html"><![CDATA[There is a recurring pattern in enterprise software history: technologies that initially promised dramatic productivity gains by hiding complexity eventually created large applications that became difficult or impossible to maintain.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.thomas-witt.com/assets/images/posts/2026-07-04-lotus-notes.jpeg" /><media:content medium="image" url="https://www.thomas-witt.com/assets/images/posts/2026-07-04-lotus-notes.jpeg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Connecting Your Unifi UDM Dream Machine Directly to Your Fiber Internet</title><link href="https://www.thomas-witt.com/blog/connecting-your-udm-pro-directly-to-your-fiber-internet/" rel="alternate" type="text/html" title="Connecting Your Unifi UDM Dream Machine Directly to Your Fiber Internet" /><published>2026-06-08T00:00:00+00:00</published><updated>2026-06-10T10:40:24+00:00</updated><id>https://www.thomas-witt.com/blog/connecting-your-udm-pro-directly-to-your-fiber-internet</id><content type="html" xml:base="https://www.thomas-witt.com/blog/connecting-your-udm-pro-directly-to-your-fiber-internet/"><![CDATA[<p>You sign up for fiber internet, you’ve already got a perfectly good router - a Ubiquiti
Dream Machine (Unifi UDM) - and all you want is to plug the optics plug straight into it.</p>

<p>Instead, the ISP (in my case, Cyta) hands you a box you never asked for. In my case
a Huawei OptiXStar HG8245X6-10 GPON terminal, but they’re all the same idea:
a mandatory middleman wedged between the fiber and your network. It burns power
around the clock, needs configuring, is a closed box you don’t control and its only
real job is to bridge packets. And it’s now your single point of failure - when that
box dies, your whole internet goes down with it, no matter how many shadow modes
or power backups you’ve installed.</p>

<p>The good news: you can throw it out entirely. A ~60 EUR GPON SFP stick plugs
straight into the UDM’s SFP+ WAN port, registers on the GPON network pretending
to be your old ONT, and lets the UDM run the PPPoE session itself. One box
instead of two, full control, and one less thing blinking in the rack.</p>

<p>This took me a while to get right, and I owe the breakthrough to two people on
Reddit - <strong>@Dm3Ch</strong> and <strong>@JopoSran4ik_01</strong> posting on <a href="https://www.reddit.com/r/cyprus/comments/1k49bgy/cyta_router_question/">this thread</a>.</p>

<p>Below is the complete walkthrough. I run a UDM Beast myself, but it’s the same on
a UDM Pro - or any UDM with an SFP WAN cage. The exact values I show - VLAN 42,
the serial format, the PPPoE login - are from my own ISP configuration; yours will
likely differ, but the procedure in general is identical for any GPON ISP.</p>

<blockquote>
  <p><strong>Disclaimer:</strong> This is what I did with my own line and my own hardware.
Cloning your ONT’s identity onto a third-party stick is squarely your
responsibility - check that it’s allowed under your contract, and if you brick
something or knock yourself offline, that’s on you. Keep the Huawei around
until everything works.</p>
</blockquote>

<h2 id="what-youll-need">What you’ll need</h2>

<ul>
  <li>A <strong>UniFi gateway with a free SFP/SFP+ WAN port</strong> and SSH access - that SFP
port is the only hard requirement; the exact model doesn’t matter</li>
  <li>The <strong>FS.com GPON SFP stick</strong> - exact model below</li>
  <li>Your <strong>Huawei’s GPON serial number</strong> (off the sticker on the back)</li>
  <li>Your fiber line switched to <strong>bridge mode</strong> (in my case, a quick call to your ISP)</li>
  <li>A few minutes of comfort on the command line</li>
</ul>

<h2 id="before-you-start-get-your-line-into-bridge-mode">Before you start: get your line into bridge mode</h2>

<p>You need <strong>bridged PPPoE</strong> on the right VLAN (it’s <strong>42</strong> on my line), meaning
<em>your</em> router runs the PPPoE session, not the ISP’s box. So call your ISP (or use
their chat) and ask them to switch your line to <strong>bridge mode</strong>.</p>

<p>If your terminal already runs in bridge/passthrough, you’re ready. If it
currently works as a normal router doing PPPoE itself, you need that change
before any of the following will work.</p>

<h2 id="step-1-find-your-huaweis-gpon-serial-number">Step 1: Find your Huawei’s GPON serial number</h2>

<p>Read the sticker on the Huawei - you need its GPON/ONT serial. This is
the single most important value in the whole process, because the stick has to
present <em>exactly</em> this identity to the network.</p>

<p>A little background, because it explains why the serial can look like two
completely different strings:</p>

<blockquote>
  <p><strong>Background:</strong> A GPON serial is 8 bytes total - a 4-byte vendor ID in ASCII,
followed by a 4-byte device part in hex. Huawei’s vendor ID is <code class="language-plaintext highlighter-rouge">HWTC</code>, which in
hex is <code class="language-plaintext highlighter-rouge">48 57 54 43</code>. So a Huawei GPON SN always shows up in one of two
encodings:</p>

  <p>a) <code class="language-plaintext highlighter-rouge">HWTC</code> + 8 hex chars, e.g. <code class="language-plaintext highlighter-rouge">HWTCxxxxxxxx</code>
b) the exact same value fully in hex: <code class="language-plaintext highlighter-rouge">48575443</code> + 8 hex chars, e.g.
<code class="language-plaintext highlighter-rouge">48575443xxxxxxxx</code></p>
</blockquote>

<p>Depending on the manufacturer, it might be printed one of those two ways:</p>

<ul>
  <li>If it already reads <code class="language-plaintext highlighter-rouge">HWTCxxxxxxxx</code>, <strong>that is your GPON SN</strong> - use it as-is.</li>
  <li>If it reads <code class="language-plaintext highlighter-rouge">48575443xxxxxxxx</code>, the leading <code class="language-plaintext highlighter-rouge">48575443</code> is just ASCII for
<code class="language-plaintext highlighter-rouge">HWTC</code>. Mentally swap it back: <code class="language-plaintext highlighter-rouge">48575443</code> → <code class="language-plaintext highlighter-rouge">HWTC</code>, keep the remaining 8 hex
chars, and you have your <code class="language-plaintext highlighter-rouge">HWTCxxxxxxxx</code> serial.</li>
</ul>

<p>From here on I’ll refer to it as <code class="language-plaintext highlighter-rouge">HWTCxxxxxxxx</code>.</p>

<h2 id="step-2-buy-the-right-sfp-stick">Step 2: Buy the right SFP stick</h2>

<p>Buy exactly <strong>this</strong> stick from FS.com - not a generic lookalike, this specific
one with the web GUI:</p>

<ul>
  <li><strong>GPON-SFP-ONT-MAC-I</strong>, SKU <strong>351553</strong>, ~60 EUR</li>
  <li><em>Generic Compatible GPON ONU SFP Class B+ Ind Web GUI</em></li>
  <li><a href="https://www.fs.com/products/351553.html">https://www.fs.com/products/351553.html</a></li>
</ul>

<p>The “MAC-I” / Web GUI variant matters: it lets you set the ONT serial, MAC and
vendor ID, both over SSH and through a small web console. That’s the whole trick.</p>

<h2 id="step-3-find-the-stick-on-your-udm">Step 3: Find the stick on your UDM</h2>

<p>Plug the FS stick into the <strong>SFP port of your UDM’s WAN</strong>. It should show up in
the Port Manager with its MAC - but you need the <em>interface name</em> the UDM uses
internally, so SSH is the reliable way.</p>

<p>SSH into the UDM (you may need to enable SSH first in the UniFi settings), then
list the links and look for the matching MAC:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ip <span class="nt">-br</span> <span class="nb">link</span>
</code></pre></div></div>

<p>Find the line whose MAC matches the stick. If you’re not sure which one it is,
run <code class="language-plaintext highlighter-rouge">ip monitor link</code> and plug/unplug the stick a couple of times - the interface
that appears and disappears is the one.</p>

<p>In my case the stick sat in <strong>port 13</strong>, which the UDM exposes as <code class="language-plaintext highlighter-rouge">eth12</code> - so
<strong>port 13 = <code class="language-plaintext highlighter-rouge">dev eth12</code></strong>. Yours may differ; substitute your interface name
wherever I write <code class="language-plaintext highlighter-rouge">eth12</code> below.</p>

<h2 id="step-4-reach-and-log-into-the-stick">Step 4: Reach and log into the stick</h2>

<p>The has a default IP: <strong><code class="language-plaintext highlighter-rouge">192.168.101.1</code></strong>. To connect to it, give your UDM an
address on that subnet, pointed at the stick’s interface:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ip addr add 192.168.101.2/24 dev eth12
</code></pre></div></div>

<p>Now check you can reach it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ping <span class="nt">-c</span> 3 <span class="nt">-I</span> 192.168.101.2 192.168.101.1
</code></pre></div></div>

<p>If the pings come back, SSH onto the stick (password: <code class="language-plaintext highlighter-rouge">root</code>):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ssh root@192.168.101.1
</code></pre></div></div>

<p>If <em>that</em> works - congrats, you’re on the stick. Check its current identity and
GPON status:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gccli sys sn<span class="p">;</span> gccli sys mac<span class="p">;</span> gccli sys vendorid<span class="p">;</span> gccli gpon state<span class="p">;</span> gccli gpon status
</code></pre></div></div>

<p><strong>Heads up:</strong> Whenever you reboot the stick or re-edit its settings in the web
console (next step), the <code class="language-plaintext highlighter-rouge">192.168.101.2</code> address on the UDM drops off - just
re-run the <code class="language-plaintext highlighter-rouge">ip addr add</code> line above to get back in. After your internet is
up and running, you most likely don’t need to log onto it ever again</p>

<h2 id="step-5-open-a-tunnel-for-the-web-console">Step 5: Open a tunnel for the web console</h2>

<p>The stick also has a web UI on port 80, but it’s only reachable from the UDM. So
log out of both the stick and the UDM, then log back into the UDM with an SSH
tunnel that forwards your local port 8888 to the stick’s web server:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ssh &lt;udm&gt; <span class="nt">-L</span> 8888:192.168.101.1:80
</code></pre></div></div>

<p>In that same shell, hop back onto the stick:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ssh root@192.168.101.1
</code></pre></div></div>

<p>Now open the stick’s admin console in your browser (login <code class="language-plaintext highlighter-rouge">admin</code> / <code class="language-plaintext highlighter-rouge">admin</code>):</p>

<p><a href="http://localhost:8888">http://localhost:8888</a></p>

<h2 id="step-6-set-pon-mode-and-the-ont-identity">Step 6: Set PON Mode and the ONT identity</h2>

<p>Two things happen here - one in the web UI, one over SSH.</p>

<p><strong>In the web console</strong>, first switch the PON Mode from <em>Auto</em> to <em>GPON</em>, per
FS.com’s <a href="https://resource.fs.com/mall/resource/gpon-sfp-ont-mac-i-configuration-guide-20260306145537.pdf">official configuration guide</a>:</p>

<blockquote>
  <p><strong>WAN Configuration → PON Mode → GPON</strong></p>
</blockquote>

<p>Let it reboot.</p>

<p>Log back into the web console and go to <strong>ONT Authentication</strong>:</p>

<ul>
  <li>Set the <strong>SN</strong> to your <code class="language-plaintext highlighter-rouge">HWTCxxxxxxxx</code> value from Step 1.</li>
  <li>Leave the <strong>password</strong> field <strong>empty</strong>.</li>
  <li>Leave the <strong>LOID</strong> field <strong>empty</strong> - my ISP doesn’t use it. (If you want to be
safe, you can also put the SN in the LOID field, still with no password.)
Your mileage may vary here.</li>
</ul>

<p><strong>Then, over SSH on the stick</strong>, set the same identity and persist it. Use your
real <code class="language-plaintext highlighter-rouge">HWTCxxxxxxxx</code> serial, and the MAC you want the stick to present (the
<code class="language-plaintext highlighter-rouge">AA:BB:CC:DD:EE:FF</code> below is a placeholder):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gccli sys sn HWTCxxxxxxxx<span class="p">;</span> gccli sys mac AA:BB:CC:DD:EE:FF<span class="p">;</span> gccli sys vendorid HWTC<span class="p">;</span> gccli sys save<span class="p">;</span> <span class="nb">sync</span><span class="p">;</span> reboot
</code></pre></div></div>

<p>After it comes back, open the stick’s <strong>status page</strong> in the web console and
confirm the <strong>MAC</strong> and <strong>ONT authentication</strong> are set correctly. Get this right
<em>before</em> you touch the fiber - almost every failure later traces back to a wrong
value here.</p>

<h2 id="step-7-configure-the-udm-pro-for-pppoe">Step 7: Configure the UDM Pro for PPPoE</h2>

<p>Over in the <strong>UDM Pro console</strong>, set up the WAN:</p>

<ul>
  <li>Internet connection type: <strong>PPPoE</strong></li>
  <li>Credentials: on my line none are actually checked - I just use a dummy</li>
  <li><strong>VLAN: 42</strong> (whatever your ISP told you)</li>
</ul>

<p>Then, in the UDM Pro’s <strong>Console Settings</strong>, set <strong>MSS Clamping</strong> to <strong>Custom:
<code class="language-plaintext highlighter-rouge">1452</code></strong>. (PPPoE eats 8 bytes of MTU overhead - 1500 → 1492 - and clamping the
TCP MSS to 1452 avoids the classic “some sites load, some hang forever” PPPoE
MTU mess.) This is also highly provider dependent, so make sure to check that.</p>

<h2 id="step-8-swap-the-fiber-and-watch-it-register">Step 8: Swap the fiber and watch it register</h2>

<p>This is the moment of truth. <strong>Unplug the optical cable from the Huawei and plug
it into the UDM’s stick.</strong></p>

<p>Watch the stick walk up the GPON state machine from <strong>O1</strong> (no optical signal) to
<strong>O5</strong> (registered). Confirm with:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gccli gpon status
</code></pre></div></div>

<p>Once you’re at <strong>O5</strong> and the UDM’s PPPoE session comes up, you’re online -
directly, with the Huawei sitting in a drawer.</p>

<h2 id="troubleshooting-always-check-the-gpon-state-first">Troubleshooting: always check the GPON state first</h2>

<p>The golden rule: <strong>check the GPON state before you debug anything else.</strong> PPPoE
and VLAN settings are irrelevant if the stick never registers on the fiber.</p>

<p>On the FS stick:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gccli gpon state<span class="p">;</span> gccli gpon status
</code></pre></div></div>

<p>You want <strong>O5</strong>. If you’re not there, don’t waste a second on PPPoE or VLAN
yet - work the optical/auth layer first:</p>

<ul>
  <li><strong>O1 after connecting the fiber</strong> → the stick doesn’t see an optical signal.
Check the fiber connector (and that you actually moved the cable over from the
Huawei).</li>
  <li><strong>O2 / O3 / O4 but never O5</strong> → GPON authentication issue. Recheck the <strong>SN</strong>,
<strong>MAC</strong> and <strong>vendor ID</strong>, and whether your line needs any other ONT fields.</li>
  <li><strong>O5 but no Internet</strong> → GPON is working, the hard part is done. <em>Now</em> debug the
UDM: PPPoE, VLAN 42 (vs. “Automatic”), and MTU/MSS clamping.</li>
</ul>

<p>That’s it. The Huawei is gone, the UDM Pro terminates fiber and PPPoE on its own,
and you’ve got one fewer black box between you and the internet.</p>

<p>I hope this saves the next person a few evenings.</p>]]></content><author><name>Thomas Witt</name></author><category term="tech" /><summary type="html"><![CDATA[You sign up for fiber internet, you’ve already got a perfectly good router - a Ubiquiti Dream Machine (Unifi UDM) - and all you want is to plug the optics plug straight into it.]]></summary></entry><entry><title type="html">How I freed a pool heat pump from an unencrypted Chinese cloud server</title><link href="https://www.thomas-witt.com/blog/how-to-free-a-pool-heat-pump-from-an-unencrypted-chinese-server/" rel="alternate" type="text/html" title="How I freed a pool heat pump from an unencrypted Chinese cloud server" /><published>2026-05-03T00:00:00+00:00</published><updated>2026-05-03T12:05:34+00:00</updated><id>https://www.thomas-witt.com/blog/how-to-free-a-pool-heat-pump-from-an-unencrypted-chinese-server</id><content type="html" xml:base="https://www.thomas-witt.com/blog/how-to-free-a-pool-heat-pump-from-an-unencrypted-chinese-server/"><![CDATA[<p>I have a pre-installed pool heat pump - an “AcquaSource” branded unit, the kind you can buy at any pool store in Europe - which supports WiFi. The App called “Pool Panel” wasn’t pretty, but it worked and I didn’t give it much thought. At one time, the remote control of the pump stopped responding: The pump itself was fine; the panel worked, the temperature held. So I decided to take a deeper look at how it all works. It turned out to be a security nightmare.</p>

<p>As icing on the cake: Their iOS app “<a href="https://apps.apple.com/app/pool-panel/id1441006970">Pool panel</a>” by the developer “Guangzhou Wo Jie Information Technology Co., Ltd” is unmaintained since 2019, the contact link leads to a broken link (<a href="https://www.axen-heatpump.com/contactus.html">https://www.axen-heatpump.com/contactus.html</a>), nobody responded via email. Very trustworthy.</p>

<p>This is the story of how I got control back, learned a few uncomfortable things along the way, and ended up with a small Docker container that exposes my pool pump as a clean local REST API.</p>

<p>tl;dr: <a href="https://github.com/thomaswitt/poolpump">Show me the code</a>!</p>

<blockquote>
  <p><strong>Disclaimer:</strong> if you try any of this at home, you’re on your own. I’m describing what I did with my own device on my own network. If you break your device, that’s on you. Also: Don’t poke at devices that aren’t yours.</p>
</blockquote>

<h2 id="the-first-uncomfortable-thing">The first uncomfortable thing</h2>

<p>Before doing anything clever, I sniffed the pump’s network traffic with <code class="language-plaintext highlighter-rouge">tcpdump</code>. The pump has a Wi-Fi module (a Hi-Flying HF-LPB130 - keep that part in mind, we’ll come back to that later) and dials out to the internet on its own. What I saw:</p>

<ul>
  <li>TCP, port <strong>502</strong>. That’s the standard Modbus port.</li>
  <li>Plain text, no TLS. Everything in the clear, unencrypted.</li>
  <li>Destination: <code class="language-plaintext highlighter-rouge">47.254.152.109</code> - an Alibaba Cloud IP in mainland China. Reverse-DNS: <code class="language-plaintext highlighter-rouge">fzdbiology.com</code>.</li>
</ul>

<p>So that means, if you can sniff the communication between the pump or the mobile
in any way, you can remote control any pump. Set it to 40 degrees on boost most
in the middle of the winter for example. So, not good.</p>

<p>There’s also an HTTP API at <code class="language-plaintext highlighter-rouge">fzdbiology.com:8080</code> (also unencrypted, plain HTTP - not HTTPS) that the iOS app uses for login and “give me the current state of my pump”. When I pointed a browser there, I got a Java-style admin panel where I could see my own device, my email address, and a couple of numbers I half-recognized. In Chinese only. :-/</p>

<p>It also seemed to me that the <em>device-side</em> protocol on <code class="language-plaintext highlighter-rouge">:502</code> is identified by MAC address only. There’s no token, no certificate, no per-device shared secret that I could see. If you know a device’s MAC, the cloud appears to forward control commands to whoever’s currently connected as that MAC. I didn’t actually try connecting as someone else’s pump, but clearly security hasn’t been any concern when designing this.</p>

<p>I’m sure with more time you could connect as someone else’s pump and watch what their iOS app sends — set their pool to 40 °C, flip them into boost mode at 03:00. I didn’t try, but the surface area is right there.</p>

<p>Additionally, I wanted to gain more control - for example that the pump shouldn’t heat overnight and only start heating if it makes sense in terms of outside temperature.</p>

<p>So I decided to cut the cord to this unencrypted, weird Chinese server, which was constantly chatting with the heat pump.</p>

<h2 id="recognising-a-white-label">Recognising a white-label</h2>

<p>I expected to be on my own with this. AcquaSource sells under several brand names (just google “47.254.152.109”, you’ll find brands like “Mundoclima”, “Thermway”, “Powerpool”, Proteam”, “AES” and more all around the world); the manuals are translations of translations.</p>

<p>When you try to connect the pump, it starts a wifi called <code class="language-plaintext highlighter-rouge">HF-LPB130</code>. This seems to be a common SOC module when it comes to heat pumps of any kind. Fortunately, some other people already did some research on that already:</p>

<ul>
  <li><a href="https://github.com/s10l/deye-logger-at-cmd"><code class="language-plaintext highlighter-rouge">s10l/deye-logger-at-cmd</code></a> - solar inverter loggers using the same module, with documented <code class="language-plaintext highlighter-rouge">AT+</code> commands for changing the cloud server.</li>
  <li><a href="https://github.com/Hypfer/deye-microinverter-cloud-free"><code class="language-plaintext highlighter-rouge">Hypfer/deye-microinverter-cloud-free</code></a> - exactly the same idea I was about to attempt, but for solar inverters: redirect the module from the vendor cloud to your own server.</li>
  <li><a href="https://github.com/davidrapan/ha-solarman"><code class="language-plaintext highlighter-rouge">davidrapan/ha-solarman</code></a> - a Home Assistant integration for the same family of devices.</li>
</ul>

<p>Big shoutout to those repo owners and the work they put into reverse-engineering!</p>

<p>The HF-LPB130 turns out to be a generic Wi-Fi-to-Modbus bridge. The vendors all use it the same way: the device speaks Modbus over a serial UART; the module re-frames it as Modbus-TCP-style MBAP packets and dials a hard-coded server. Anyone who buys this module in volume gets the same primitive: a serial-to-cloud pipe that you can re-target with a few <code class="language-plaintext highlighter-rouge">AT+</code> commands over UDP/48899.</p>

<p>That meant two things: (1) I could get the module to talk to <em>my</em> server instead of <code class="language-plaintext highlighter-rouge">fzdbiology.com</code>, and (2) once it did, I’d be receiving Modbus-TCP-shaped frames - a known protocol with widely available parsers, plus a vendor-specific extension for heartbeats.</p>

<p>What I didn’t have was the pump-vendor-specific bit: which Modbus register holds the on/off bit, which one holds the setpoint, what the function codes mean.</p>

<h2 id="phase-1-cheating-with-mitmproxy">Phase 1: cheating with mitmproxy</h2>

<p>Before doing the hard reverse-engineering work, I did the easy reverse-engineering work. I pointed my iPhone at a <code class="language-plaintext highlighter-rouge">mitmproxy</code> instance on my laptop, opened the iOS app, and watched it log into <code class="language-plaintext highlighter-rouge">fzdbiology.com:8080</code>.</p>

<p>The HTTP API is verbose: <code class="language-plaintext highlighter-rouge">getRtuRealTime</code> returns a JSON document with about 100 named fields per device - <code class="language-plaintext highlighter-rouge">model</code>, <code class="language-plaintext highlighter-rouge">function</code>, <code class="language-plaintext highlighter-rouge">heattemp</code>, <code class="language-plaintext highlighter-rouge">cooltemp</code>, <code class="language-plaintext highlighter-rouge">pa10</code>, <code class="language-plaintext highlighter-rouge">pa15</code>, <code class="language-plaintext highlighter-rouge">ap2</code>, <code class="language-plaintext highlighter-rouge">ap3</code>, <code class="language-plaintext highlighter-rouge">pb11</code>. Some are obviously meaningful; some are just opaque registers. I wrote a small Ruby tool (<code class="language-plaintext highlighter-rouge">tools/cloud_probe.rb</code> in the repo) that wraps <code class="language-plaintext highlighter-rouge">loginUser</code> + <code class="language-plaintext highlighter-rouge">getRtuRealTime</code> and dumps a snapshot to disk. Two snapshots, taken on either side of pressing a button on the pump’s physical panel, told me what each field represented:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ ruby tools/cloud_probe.rb diff before.json after.json
model: 4 → 2          # ← I pressed "heat mode"
heattemp: 27 → 28     # ← I bumped the setpoint
</code></pre></div></div>

<p>That gave me the <em>vocabulary</em>. It did not give me the <em>protocol</em> - the cloud was translating between the iOS app’s verbs and the device’s Modbus registers, and I needed to know how that translation worked if I was going to replace the cloud.</p>

<p>I then also decompiled the Android app, which basically gave me the same findings I already had from sniffing with mitmproxy.</p>

<h2 id="phase-2-io-digging">Phase 2: I/O digging</h2>

<p>I had two options: keep poking at the device passively, or impersonate the cloud and let the device tell me what it wanted to hear. I did both.</p>

<p><strong>Passive sniffer.</strong> A small Ruby script that listens on TCP/502, parses Modbus-TCP framing, ACKs every push the device makes, and logs the bytes with timestamps. Nothing the device sends gets dropped, and once you ACK its first vendor heartbeat, it sends a full register sweep every couple of seconds.</p>

<p><strong>Active cloud-impersonator.</strong> Another small script that does the <em>opposite</em>: opens a TCP connection to <code class="language-plaintext highlighter-rouge">fzdbiology.com:502</code>, sends the heartbeat the real device would send (using my own pump’s MAC, since the cloud only auths by MAC), and waits for what comes back. With the iOS app running, it turned out that every button I pressed in the app turned into a 12-byte frame on my impersonator’s socket. I could line up “I just pressed boost” against <code class="language-plaintext highlighter-rouge">fc=0x06 addr=0x07d2 value=0x0400</code> and conclude that register <code class="language-plaintext highlighter-rouge">0x07d2</code> is the function selector and <code class="language-plaintext highlighter-rouge">0x0400</code> means “boost”.</p>

<p>Forty minutes of button-pressing later, I had the user-facing control surface decoded:</p>

<table>
  <thead>
    <tr>
      <th>addr</th>
      <th>meaning</th>
      <th>values</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>2000</td>
      <td>mode</td>
      <td><code class="language-plaintext highlighter-rouge">0x01</code> = auto, <code class="language-plaintext highlighter-rouge">0x02</code> = cool, <code class="language-plaintext highlighter-rouge">0x04</code> = heat</td>
    </tr>
    <tr>
      <td>2001</td>
      <td>on/off</td>
      <td>0 / 1</td>
    </tr>
    <tr>
      <td>2002</td>
      <td>function</td>
      <td><code class="language-plaintext highlighter-rouge">0x0000</code> = smart, <code class="language-plaintext highlighter-rouge">0x0010</code> = silent, <code class="language-plaintext highlighter-rouge">0x0400</code> = boost</td>
    </tr>
    <tr>
      <td>2006</td>
      <td>setpoint</td>
      <td>integer °C, range 8–40</td>
    </tr>
  </tbody>
</table>

<p>Modes and functions are bit-encoded - <code class="language-plaintext highlighter-rouge">0x04</code> for heat is bit 2; silent (<code class="language-plaintext highlighter-rouge">0x0010</code>) is bit 4 and boost (<code class="language-plaintext highlighter-rouge">0x0400</code>) is bit 10 in the same word, with smart represented by no bits set at all. The earlier hypothesized mapping I’d seen in other projects (<code class="language-plaintext highlighter-rouge">auto=0</code>, <code class="language-plaintext highlighter-rouge">silent=1</code>, <code class="language-plaintext highlighter-rouge">boost=3</code>) was an enum guess; the device actually wants flag bits.</p>

<p>I did the second phase with Claude Code as a pair, which kept the test suite green while I stress-tested protocol theories at the keyboard - which was extraordinarily helpful, especially when it came to observing bits and registers.</p>

<h2 id="phase-3-a-docker-container-that-speaks-pool">Phase 3: a Docker container that speaks pool</h2>

<p>The end product is small: a single Ruby process that listens on two ports inside a Docker container plus a couple of scripts which help to set up the initial configuration to point the pump to my local network.</p>

<ul>
  <li><strong>Port 502</strong> - the real pump dials in here. The container parses Modbus-TCP pushes, maintains an in-memory register snapshot, and sends Modbus writes back when something on the local network asks for a control change.</li>
  <li><strong>Port 8090</strong> - a flat REST API for everyone else.</li>
</ul>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>curl http://homeserver.example.com:8090/
<span class="o">{</span>
  <span class="s2">"SWITCHED_ON"</span>: 1,
  <span class="s2">"TEMP_TARGET"</span>: 28,
  <span class="s2">"TEMP_AMBIENT"</span>: 17,
  <span class="s2">"TEMP_OUTLET"</span>: 18,
  <span class="s2">"BOOST"</span>: 1,
  <span class="s2">"SILENCE"</span>: 0,
  <span class="s2">"STATUS_MODE"</span>: 2,
  <span class="s2">"STATUS_MALFUNC"</span>: <span class="s2">"none"</span>,
  ...
<span class="o">}</span>

<span class="nv">$ </span>curl <span class="nt">-X</span> POST <span class="nt">-d</span> <span class="s2">"settemp 28"</span> http://homeserver.example.com:8090/
<span class="o">{</span> <span class="s2">"result"</span>: <span class="s2">"ok"</span>, <span class="s2">"verb"</span>: <span class="s2">"settemp 28"</span>, <span class="s2">"snapshot"</span>: <span class="o">{</span> ... <span class="o">}</span> <span class="o">}</span>
</code></pre></div></div>

<p>End-to-end latency from <code class="language-plaintext highlighter-rouge">curl</code> to physical pump is well under a second. The container sends the Modbus write, the pump echoes the same frame back over TCP within ~150 ms, and the HTTP request resolves at that moment. The full register-block sweep runs every ~17 s in the background and updates the snapshot, but commands don’t have to wait for it.</p>

<p>One thing I noticed is that the HF-LPB130 only accepts hostnames, not IP addresses. So you need to have some public domain which resolves into your 192.168.x.x address</p>

<h2 id="phase-4-deploying-it">Phase 4: Deploying it</h2>

<p>The whole thing runs on a Raspberry Pi 4. The Pi has a stable LAN IP, an A-record at <code class="language-plaintext highlighter-rouge">homeserver.example.com</code> pointing at it, and <code class="language-plaintext highlighter-rouge">docker compose up -d --build</code> brings the container up at boot. The pump’s Wi-Fi module is configured (one-time, via the OEM <code class="language-plaintext highlighter-rouge">AT+</code> command set) to dial <code class="language-plaintext highlighter-rouge">homeserver.example.com:502</code> instead of the Chinese cloud. If I ever move the container to a different host, I just flip the DNS A-record - the module re-resolves on its next reconnect and lands on the new server. No more touching the pump.</p>

<h2 id="integrating-it-into-smart-home-systems">Integrating it into smart home systems</h2>

<p>Within Homey Pro I created a virtual device that represents the pump, plus a HomeyScript that runs every five minutes (or whenever the virtual device’s state changes). The script implements a soft-intent model: turning on the virtual device doesn’t directly turn on the pump — it only fires up if the outside temperature (measured by a Netatmo sensor) is above a sensible threshold and we’re inside a daytime heating window.</p>

<p>Reading the manual carefully, I also learned that the pump’s “silent” mode isn’t just acoustically quieter — it’s actually the most energy-efficient setting. So the script pins the pump to silent by default and only flips to “boost” when I explicitly ask for fast heating via a separate Boost button on the virtual device.</p>

<p>If you have the same pump, the same module, or just the same general “I want my IoT thing to stop calling home” problem, <a href="https://github.com/thomaswitt/poolpump">the code is on GitHub</a>.</p>

<p>I hope it’s useful for somebody with the same problem.</p>]]></content><author><name>Thomas Witt</name></author><category term="IOT" /><category term="SmartHome" /><summary type="html"><![CDATA[I have a pre-installed pool heat pump - an “AcquaSource” branded unit, the kind you can buy at any pool store in Europe - which supports WiFi. The App called “Pool Panel” wasn’t pretty, but it worked and I didn’t give it much thought. At one time, the remote control of the pump stopped responding: The pump itself was fine; the panel worked, the temperature held. So I decided to take a deeper look at how it all works. It turned out to be a security nightmare.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.thomas-witt.com/assets/images/posts/2026-05-03-pool-pump.jpeg" /><media:content medium="image" url="https://www.thomas-witt.com/assets/images/posts/2026-05-03-pool-pump.jpeg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">I Read the Anthropic Legal Prompts That Crashed $285B in Stocks</title><link href="https://www.thomas-witt.com/blog/285-billion-wiped-out-because-of-a-text-file/" rel="alternate" type="text/html" title="I Read the Anthropic Legal Prompts That Crashed $285B in Stocks" /><published>2026-02-05T00:00:00+00:00</published><updated>2026-02-05T10:51:56+00:00</updated><id>https://www.thomas-witt.com/blog/285-billion-wiped-out-because-of-a-text-file</id><content type="html" xml:base="https://www.thomas-witt.com/blog/285-billion-wiped-out-because-of-a-text-file/"><![CDATA[<p>On February 3, 2026, tech stocks went into free fall. Thomson Reuters dropped
15.83% — its biggest single-day decline on record. LegalZoom fell 19.68%. The
Goldman Sachs US software basket lost 6%. Total damage: <strong>$285 billion in market
cap</strong>, gone in a single session.</p>

<p>Bloomberg ran the headline: <a href="https://www.bloomberg.com/news/articles/2026-02-03/legal-software-stocks-plunge-as-anthropic-releases-new-ai-tool">“Anthropic AI Tool Sparks Selloff From Software
to Broader
Market.”</a></p>

<p>So I went and read what Anthropic actually shipped.</p>

<h2 id="what-anthropic-actually-released">What Anthropic Actually Released</h2>

<p>On January 30, Anthropic open-sourced
<a href="https://github.com/anthropics/knowledge-work-plugins">eleven plugins</a> for
Claude Cowork, their agentic desktop app. One of those plugins was for
<a href="https://github.com/anthropics/knowledge-work-plugins/tree/main/legal">legal work</a> —
six subdirectories of plain text files: <code class="language-plaintext highlighter-rouge">contract-review</code>, <code class="language-plaintext highlighter-rouge">nda-triage</code>,
<code class="language-plaintext highlighter-rouge">compliance</code>, <code class="language-plaintext highlighter-rouge">legal-risk-assessment</code>, <code class="language-plaintext highlighter-rouge">meeting-briefing</code>, and
<code class="language-plaintext highlighter-rouge">canned-responses</code>.</p>

<p>No new model. No API. No product launch. A GitHub repo with ~2,500 lines of
structured prompt instructions. The kind of thing thousands of developers write
every day when building on top of LLMs.</p>

<p>Here’s the core of the
<a href="https://github.com/anthropics/knowledge-work-plugins/blob/main/legal/skills/contract-review/SKILL.md">contract review methodology</a>,
quoted directly from the repo:</p>

<blockquote>
  <ol>
    <li><strong>Identify the contract type</strong>: SaaS agreement, professional services, license, partnership, procurement, etc.</li>
    <li><strong>Determine the user’s side</strong>: Vendor, customer, licensor, licensee, partner.</li>
    <li><strong>Read the entire contract</strong> before flagging issues. Clauses interact with each other.</li>
    <li><strong>Analyze each material clause</strong> against the playbook position.</li>
    <li><strong>Consider the contract holistically</strong>: Are the overall risk allocation and commercial terms balanced?</li>
  </ol>
</blockquote>

<p>That’s the review process. The entire methodology. Identify, determine sides,
read, analyze, consider holistically. This is what a law school student probably
learns on day one.</p>

<h2 id="first-year-law-school-material">First-Year Law School Material</h2>

<p>The NDA triage skill is a 10-point checklist: agreement structure, definition
scope, obligations, standard carveouts, permitted disclosures, term,
return/destruction, remedies, problematic provisions, governing law. Every
in-house legal team has this document pinned somewhere. The green/yellow/red
classification system is a standard risk matrix — the same framework taught in
corporate legal training.</p>

<p>Don’t get me wrong: The prompts are well-crafted. But: They’re not magic.
They’re structured instructions for tasks that legal professionals have been
doing for decades. And they’re <strong>open source</strong> — anyone can read them, copy
them, modify them. You can run them in OpenAI. Or an Open Source Model like
DeepSeek.</p>

<p>There is no competitive advantage here that couldn’t be replicated by a
competent developer in an afternoon.</p>

<h2 id="the-information-asymmetry-is-the-story">The Information Asymmetry Is the Story</h2>

<p>The repo is public. Anyone could have read it in 10 minutes. The market priced
in fear of something that’s fully auditable on GitHub. That gap between
perception and reality is the actual story.</p>

<p>So $285 bn lost — not because of a product launch, but because of a markdown
file in a GitHub repo. Investors didn’t click through. They didn’t read the
prompts. They didn’t ask a single engineer what a “skill plugin” actually is.
They saw “Anthropic” and “legal” in the same sentence and hit sell.</p>

<p>This isn’t an AI disruption story. This is a <strong>market literacy story</strong>. The
selloff tells us nothing about AI’s impact on the legal profession and everything
about how poorly the market understands what AI companies actually ship.</p>

<h2 id="what-this-actually-tells-builders">What This Actually Tells Builders</h2>

<p>Anthropic published these prompts freely because the prompts aren’t the product.</p>

<p>The “moat” for vertical AI isn’t prompt engineering — it’s execution, trust,
integration, compliance, and liability acceptance. Anthropic just demonstrated
that by giving the prompts away. What does that tell you about every “AI
wrapper” startup whose entire value prop is a system prompt?</p>

<h2 id="the-real-question">The Real Question</h2>

<p>AI will reshape law firms, consultancies, and half the NASDAQ. That’s not
controversial. The “software eating software” thesis has been in every VC deck
since 2023. Claude Code, Cursor, Codex — they all exist. Nobody disputes the
direction.</p>

<p>So why did a GitHub commit containing first-year law school content trigger a
$285 billion repricing? Either the market hadn’t priced in something that’s been
“obvious to everyone” — meaning it wasn’t actually obvious to investment
professionals — or they overreacted to a headline without reading the repo.
Both are equally frightening.</p>

<p>And here’s the kicker: this repo is <em>public</em>. If the people managing your money
can’t do due diligence on something freely auditable on GitHub, what are they
diligencing behind closed doors?</p>

<p>If your investment thesis can be wrecked by a README file, maybe the thesis was
never there to begin with.</p>]]></content><author><name>Thomas Witt</name></author><category term="AI" /><summary type="html"><![CDATA[On February 3, 2026, tech stocks went into free fall. Thomson Reuters dropped 15.83% — its biggest single-day decline on record. LegalZoom fell 19.68%. The Goldman Sachs US software basket lost 6%. Total damage: $285 billion in market cap, gone in a single session.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.thomas-witt.com/assets/images/posts/2026-02-05-text-file.jpeg" /><media:content medium="image" url="https://www.thomas-witt.com/assets/images/posts/2026-02-05-text-file.jpeg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">From a Stalled Map to an Async AWS SDK: Why I Built aws-sdk-http-async</title><link href="https://www.thomas-witt.com/blog/aws-sdk-http-async/" rel="alternate" type="text/html" title="From a Stalled Map to an Async AWS SDK: Why I Built aws-sdk-http-async" /><published>2025-01-19T00:00:00+00:00</published><updated>2026-01-20T11:35:32+00:00</updated><id>https://www.thomas-witt.com/blog/aws-sdk-http-async</id><content type="html" xml:base="https://www.thomas-witt.com/blog/aws-sdk-http-async/"><![CDATA[<p>I maintain a side project called <a href="https://airfield.directory">Airfield
Directory</a>, a Rails app backed by
<a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/">DynamoDB</a>
for general aviation pilots.</p>

<p>Because DynamoDB access is essentially all network I/O over HTTPS, it is a good
playground to tinker with Ruby fibers before bringing the same approach into
other production systems.</p>

<p>Airfield Directory runs on Falcon because I want fiber-based concurrency without
the memory overhead of thread-per-request. It should feel fast and smooth under
load.</p>

<p>Then the <a href="https://airfield.directory/search">dynamic map</a> happened.</p>

<p>The map view fans out a lot of DynamoDB queries across H3 hexagonal grid cells
(a system invented by Uber). Under Falcon, I expected those requests to overlap
in fibers. Instead, the reactor stalled and the app behaved like it was
single-threaded again: latency spikes, janky scrolling, that “it’s fast until it
isn’t” feel.</p>

<p>My first instinct was to do what I do elsewhere (e.g. ruby_llm): wrap the hot
path in <code class="language-plaintext highlighter-rouge">Async { ... }</code> and trust the scheduler. It didn’t help. The AWS SDK was
still blocking the reactor.</p>

<h2 id="trying-to-explain-why-the-aws-sdk-for-ruby-fights-falcon">Trying to explain why the AWS SDK for Ruby fights Falcon</h2>

<p>The AWS SDK’s default HTTP transport uses Net::HTTP wrapped in a connection
pool. Contrary to what you might expect, Net::HTTP itself <em>is</em> fiber-friendly in
Ruby 3.0+—the fiber scheduler hooks into blocking I/O and yields to other fibers
automatically.</p>

<p>But in practice, those implicit hooks don’t yield reliably, so the single
reactor thread gets blocked often enough that all fibers serialize.</p>

<p>What I actually saw in production:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">barrier</span> <span class="o">=</span> <span class="no">Async</span><span class="o">::</span><span class="no">Barrier</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="ss">parent: </span><span class="n">parent_task</span><span class="p">)</span>
<span class="n">batch_cells</span><span class="p">.</span><span class="nf">each</span> <span class="p">{</span> <span class="o">|</span><span class="nb">id</span><span class="o">|</span> <span class="n">barrier</span><span class="p">.</span><span class="nf">async</span> <span class="p">{</span> <span class="n">dynamodb</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span><span class="o">...</span><span class="p">)</span> <span class="p">}</span> <span class="p">}</span>
<span class="n">barrier</span><span class="p">.</span><span class="nf">wait</span>
</code></pre></div></div>

<p>-&gt; Observed: serialized request time</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">threads</span> <span class="o">=</span> <span class="n">batch_cells</span><span class="p">.</span><span class="nf">map</span> <span class="p">{</span> <span class="o">|</span><span class="nb">id</span><span class="o">|</span> <span class="no">Thread</span><span class="p">.</span><span class="nf">new</span> <span class="p">{</span> <span class="n">dynamodb</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span><span class="o">...</span><span class="p">)</span> <span class="p">}</span> <span class="p">}</span>
<span class="n">threads</span><span class="p">.</span><span class="nf">each</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:join</span><span class="p">)</span>
</code></pre></div></div>

<p>-&gt; Observed: 1× single request time (concurrent)</p>

<p>So my current observation/explanation is:</p>

<ul>
  <li>The SDK’s Net::HTTP transport is synchronous end‑to‑end and relies on implicit scheduler hooks in Net::HTTP/OpenSSL/DNS.</li>
  <li>In our workload, that path blocked the reactor often enough that the Async::Barrier output serialized.</li>
  <li>Threads still overlapped because each call ran in its own OS thread.
Swapping the transport to async‑http fixed it because async‑http is fiber‑native end‑to‑end (pool + I/O), so the reactor can actually interleave requests.</li>
</ul>

<p>I can’t tell you in detail whether it’s gaps in hook coverage, OpenSSL handshakes, DNS resolutions, whatever … In the end, async-http seems to me to be by far the best solution in the whole Async ecosystem. Of course, there might be other side effects I have overlooked, but in the end was/is my real-life observation reproducable.</p>

<p>I eventually fell back to threads just to keep the UI responsive. It worked, but
it felt wrong.</p>

<p>I dug through the SDK internals and landed on the same conclusion captured in
this issue: <a href="https://github.com/aws/aws-sdk-ruby/issues/2621">https://github.com/aws/aws-sdk-ruby/issues/2621</a> and an
<a href="https://github.com/saluzafa/async-aws-ruby">abandoned experimental repo</a>.</p>

<p>So, in a nutshell:</p>

<ul>
  <li>Threads overlapped because each call ran in its own OS thread.</li>
  <li>Fibers with <code class="language-plaintext highlighter-rouge">Async::Barrier</code> serialized under Falcon.</li>
  <li>Swapping the transport to <code class="language-plaintext highlighter-rouge">async-http</code> fixed it—because <code class="language-plaintext highlighter-rouge">async-http</code> is fiber-native end-to-end (pool + I/O), most likely because the reactor can actually interleave requests.</li>
</ul>

<h2 id="the-solution-aws-sdk-http-async">The solution: aws-sdk-http-async</h2>

<p>I built a new HTTP handler as a gem plugin for aws-sdk-core using async-http called
<a href="https://github.com/thomaswitt/aws-sdk-http-async">aws-sdk-http-async</a>. It
aims to preserve the SDK’s semantics (retries, error handling, telemetry,
content-length validation), but make the transport fiber-friendly under Falcon.</p>

<p>Key goals:</p>

<ul>
  <li>Async transport when a reactor exists (Falcon).</li>
  <li>Automatic fallback to Net::HTTP when no reactor exists (rake/console/tests).</li>
  <li>No patches required to make CLI tasks work.</li>
  <li>Safe defaults, explicit config, and clear failure modes for event streams.</li>
</ul>

<h2 id="usage-zero-config">Usage (zero-config)</h2>

<p>Add it to your Gemfile:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">gem</span> <span class="s1">'aws-sdk-http-async'</span>
</code></pre></div></div>

<p>More information in the <a href="https://github.com/thomaswitt/aws-sdk-http-async">repo on
Github</a></p>]]></content><author><name>Thomas Witt</name></author><category term="coding" /><summary type="html"><![CDATA[I maintain a side project called Airfield Directory, a Rails app backed by DynamoDB for general aviation pilots.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.thomas-witt.com/assets/images/posts/2025-01-19-airfield-directory.jpeg" /><media:content medium="image" url="https://www.thomas-witt.com/assets/images/posts/2025-01-19-airfield-directory.jpeg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Code-Stripped: Many ‘AI Startups’ are Actually Naked</title><link href="https://www.thomas-witt.com/blog/code-stripped-many-ai-startups-are-actually-naked/" rel="alternate" type="text/html" title="Code-Stripped: Many ‘AI Startups’ are Actually Naked" /><published>2024-01-08T00:00:00+00:00</published><updated>2025-04-15T17:18:53+00:00</updated><id>https://www.thomas-witt.com/blog/code-stripped-many--ai-startups--are-actually-naked</id><content type="html" xml:base="https://www.thomas-witt.com/blog/code-stripped-many-ai-startups-are-actually-naked/"><![CDATA[<p>It’s not really a new insight when I tell you, it feels like AI is everywhere in
the startup scene especially since 2023. But here’s a spicy take from our
observations at <a href="https://www.expedite.ventures/">Expedite Ventures</a>: when you
really look at their code and dig deeper, most of these ‘AI startups’ are, well,
stark naked. Not all that glitters is AI gold.</p>

<h2 id="ai-promises-vs-reality">AI Promises vs. Reality</h2>

<p>Pitch decks are dazzling, promising AI marvels and tales of wonderlands. But
when our team of CTOs at Expedite peeks under the hood, we often find the AI is
more “artificial” than “intelligent.” When it comes to the moment of truth and
we check the code, it’s often just a fancy facade for simple algorithms. It’s
like expecting a Tesla but finding a remote controlled toy car instead.</p>

<p>Together with my fellow co-investor,
<a href="https://www.linkedin.com/in/sebastiandeutsch/">Sebastian</a>, a machine learning
wizard for years, we’ve seen a lot in 2023: Take some recent pitches we
examined, promising groundbreaking AI for data extraction. Spoiler alert: behind
the AI mask was just basic coding without a whiff of AI insight. We’ve looked at
code and models and yet, all we found was a straightforward hardcoded approach,
without a hint of AI or ML.</p>

<p>We’re noticing a pattern: startups are in love with the AI label (sometimes
rightly so when you look at the insane valuations paid for often lousy tech),
and even when they incorporate some AI components, their data sets are often as
thin as air. Even more often we’re just seeing a wrapper for the ChatGPT/OpenAI
API. While there’s nothing wrong with that in a prototyping phase, it helps to
point that out from the beginning. Also, we’ve noticed that the mindset of ‘just
quickly using the API, we’ll change that later’ can hinder the accumulation of
know-how in building, training, and operating customized models (which should be
the goal if you’re an ‘AI Startup’, right?). Intriguingly, this approach often
leads to a rapid burn rate — a hint: sometimes even unnecessary in the era of
LLAMA.</p>

<p>While we definitely don’t claim to have all the answers or see every AI startup
on the planet, the hundreds of pitches we reviewed in 2023 revealed a distinct
pattern we can’t ignore.</p>

<p>In short: Big AI dreams, sure, but the tech isn’t walking the talk. If we had
received a chocolate bar for every time we’ve seen a rules engine sold as AI, we
would have had to skip Christmas this year.</p>

<h2 id="a-call-to-startups">A Call to Startups</h2>

<p>Dear startups, let’s keep it real. If your AI is more of a future plan than a
here-and-now reality: <strong>just say so</strong>. It’s cool to be a work in progress.
Pretending otherwise? Not so much.</p>

<h2 id="investor-colleagues---look-beyond-the-ai-sparkle">Investor Colleagues - look beyond the AI Sparkle</h2>

<p>Fellow investors, don’t get lost in the AI fairy dust. Dive deep, ask hard
questions. Look beyond the shiny surface of pitch decks brandishing the ‘AI’
label (means, about 95% of all pitch decks we get these days). At Expedite
Ventures, we’re all about finding the genuine tech gems hidden in the AI noise.
And we’re more than happy to lend a magnifying glass.</p>

<h2 id="final-thoughts">Final Thoughts</h2>

<p>Innovation is more than a buzzword. It’s about bringing real, groundbreaking
tech to the table. In the end, it’s about genuine innovation, not AI fantasies.
We’re on a mission to find startups that truly push tech boundaries, not just
play dress-up with buzzwords. Many AI startups lose their shine under a
tech-savvy gaze. Remember, it’s not just about joining the AI parade; it’s about
leading it with substance.</p>

<p>Let’s champion the real tech heroes — the ones who are honest about their
journey and potential. Here’s to investing in solid ground, not just AI castles
in the cloud.</p>

<p><strong>P.S.:</strong>This isn’t just about critique; it’s about building a stronger, more
genuine tech ecosystem. And hey, at
<a href="https://www.expedite.ventures/">Expedite Ventures</a>, we’re always on the lookout
for the real tech magicians. Got an honest, groundbreaking idea? We’re all ears!</p>]]></content><author><name>Thomas Witt</name></author><category term="startups" /><summary type="html"><![CDATA[It’s not really a new insight when I tell you, it feels like AI is everywhere in the startup scene especially since 2023. But here’s a spicy take from our observations at Expedite Ventures: when you really look at their code and dig deeper, most of these ‘AI startups’ are, well, stark naked. Not all that glitters is AI gold.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.thomas-witt.com/assets/images/posts/2024-01-08-code-stripped.jpeg" /><media:content medium="image" url="https://www.thomas-witt.com/assets/images/posts/2024-01-08-code-stripped.jpeg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Download Wise.com Account Balance Statements as PDF via API</title><link href="https://www.thomas-witt.com/blog/download-wise-com-account-balance-statements-as-pdf-via-api/" rel="alternate" type="text/html" title="Download Wise.com Account Balance Statements as PDF via API" /><published>2023-11-23T00:00:00+00:00</published><updated>2025-04-15T17:18:53+00:00</updated><id>https://www.thomas-witt.com/blog/download-wise-com-account-balance-statements-as-pdf-via-api</id><content type="html" xml:base="https://www.thomas-witt.com/blog/download-wise-com-account-balance-statements-as-pdf-via-api/"><![CDATA[<p>I utilize Wise.com (formerly Transferwise) for managing several business
accounts and find it generally reliable and efficient, except for some initial
account setup annoyances. A recurring challenge is downloading account
statements, which involves multiple steps and clicks, particularly when dealing
with multiple currencies. To streamline this process, I developed a script for
automated downloads.</p>

<p>The <a href="https://docs.wise.com/api-docs/api-reference">Wise API documentation</a> is
adequate, though occasionally lacking in detail. Also, at least in the EU,
downloading balance statements requires two-factor authentication, not just a
simple API call. This process involves making an API call, receiving a
challenge, signing it with an RSA certificate, and then reissuing the request.
Although it’s not complex, it took some time to perfect.</p>

<p>I created a user-friendly script that prompts for your Wise personal API token,
account selection, statement year, and currency.</p>

<p>Setting this up requires a few steps. I recommend a trial run with a sandbox
account at <a href="https://sandbox.transferwise.tech/">https://sandbox.transferwise.tech/</a>. You’ll need to create an API
token with read-only access and back it up securely. Additionally, generate a
public key using the following commands in a cloned GitHub repository.</p>

<h4 id="step-by-step-guide-to-creating-a-wise-api-token-and-2fa-public-key">Step-by-Step Guide to Creating a Wise API Token and 2FA Public Key</h4>

<p>To set up your API keys, first go to “Settings” and then to “API tokens”:</p>

<p><img src="/assets/images/posts/2023-11-23-wise-1.png" alt="" /></p>

<ul>
  <li>Create a personal API token in Wise</li>
  <li>Click “Add new token”:</li>
</ul>

<p><img src="/assets/images/posts/2023-11-23-wise-2.png" alt="" /></p>

<ul>
  <li>Add a new token in Wise.com</li>
  <li>Read Only Access for the new token is sufficient:</li>
</ul>

<p><img src="/assets/images/posts/2023-11-23-wise-3.png" alt="" /></p>

<ul>
  <li>Read Only Token for wise.com API</li>
</ul>

<p>In addition to backing up the token in a password manager, you will also need to
generate a public key. The most convenient way to do this is by cloning my
GitHub repository containing the script and creating the key directly within
that environment:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/thomaswitt/wise-statement-downloader.git
cd wise-statement-downloader
mkdir certs
openssl genrsa -out certs/wise-private.pem 2048
openssl rsa -pubout -in certs/wise-private.pem -out certs/wise-public.pemMake sure you’ll also backup the newly created RSA key.

That’s essentially all there is to it. After completing these steps, you can run the script to easily obtain your first account statement:

thomas@mac:~/Dev/wise-statement-downloader(main) $ ./wise.bash test
*** Using Sandbox API Environment
WISE Personal API Token: [CONCEALED]
Choose account:
1: Esmeralda Beasley (2023) (17012841)
Your choice: 1

Choose year for the statement:
1: 2023
Your choice: 1

Choose currency:
1: AUD (1000000.00 AUD) (179137)
2: EUR (1000000.00 EUR) (179096)
3: GBP (1000000.00 GBP) (179136)
4: USD (1000000.00 USD) (179097)
Your choice: 2
Chosen currency: EUR

*** Writing PDF file to output/Wise-17012841-Esmeralda\_Beasley-EUR-2023.pdf
</code></pre></div></div>

<p>The script currently supports downloading annual statements, but it can be
easily modified for monthly intervals by adjusting the $STATEMENT_DETAILS
variable.</p>

<p>Now just repeat these steps in your main production account — and you’re good to
go.</p>

<p>Enjoy the convenience!</p>]]></content><author><name>Thomas Witt</name></author><category term="tech" /><summary type="html"><![CDATA[I utilize Wise.com (formerly Transferwise) for managing several business accounts and find it generally reliable and efficient, except for some initial account setup annoyances. A recurring challenge is downloading account statements, which involves multiple steps and clicks, particularly when dealing with multiple currencies. To streamline this process, I developed a script for automated downloads.]]></summary></entry><entry><title type="html">How to monitor and remove unwanted Launch Agents and Daemons in macOS</title><link href="https://www.thomas-witt.com/blog/how-to-monitor-and-remove-unwanted-launch-agents-and-daemons-in-macos/" rel="alternate" type="text/html" title="How to monitor and remove unwanted Launch Agents and Daemons in macOS" /><published>2023-11-04T00:00:00+00:00</published><updated>2025-04-21T09:05:23+00:00</updated><id>https://www.thomas-witt.com/blog/how-to-monitor-and-remove-unwanted-launch-agents-and-daemons-in-macos</id><content type="html" xml:base="https://www.thomas-witt.com/blog/how-to-monitor-and-remove-unwanted-launch-agents-and-daemons-in-macos/"><![CDATA[<p>Many macOS software installers add unnecessary background processes, such as
auto-updaters, which can be intrusive or even spyware. These “helpers” typically
install in the directories: <em>LaunchAgents</em>, <em>LaunchDaemons</em>, or
<em>PrivilegedHelperTools</em>, found in <em>/</em>Library or <em>$HOME/Library</em>. To find and
potentially remove them, you have to check these locations regularly.</p>

<p>I created a script for my .bash_profile or .zshrc (works both via bash and zsh)
to track new, unwanted additions. It doesn’t automatically delete these items
but provides removal commands every time I open a shell, which is frequently. Of
course, you could also automatically remove them if you don’t want to manually
double-check.</p>

<p>Notice: This script does not affect Login Items (accessible through System
Preferences &gt; Users &amp; Groups &gt; Login Items), where applications can be set to
launch at startup and also install background daemons (often for AppStore-based
Apps). Additionally, I created an alias to monitor these via the command line as
well (<em>show_background_tasks</em>).</p>

<p>Enjoy!</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/usr/bin/env bash</span>

<span class="c"># Remove unwanted helpers</span>
process_agents<span class="o">()</span> <span class="o">{</span>
  <span class="nb">local </span><span class="nv">directory</span><span class="o">=</span><span class="nv">$1</span>
  <span class="nb">shift
  local </span><span class="nv">agents</span><span class="o">=(</span><span class="s2">"</span><span class="nv">$@</span><span class="s2">"</span><span class="o">)</span>
  <span class="nb">local </span><span class="nv">pattern</span><span class="o">=</span><span class="si">$(</span>
    <span class="nv">IFS</span><span class="o">=</span><span class="se">\|</span>
    <span class="nb">echo</span> <span class="s2">"</span><span class="k">${</span><span class="nv">agents</span><span class="p">[*]</span><span class="k">}</span><span class="s2">"</span>
  <span class="si">)</span>
  <span class="nb">shopt</span> <span class="nt">-s</span> nullglob
  <span class="k">for </span>plist <span class="k">in</span> <span class="s2">"</span><span class="nv">$directory</span><span class="s2">"</span>/<span class="o">{</span>LaunchAgents,LaunchDaemons,PrivilegedHelperTools<span class="o">}</span>/<span class="k">*</span><span class="p">;</span> <span class="k">do
    if</span> <span class="o">[</span> <span class="o">!</span> <span class="nt">-s</span> <span class="s2">"</span><span class="nv">$plist</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then continue</span><span class="p">;</span> <span class="k">fi
    if</span> <span class="o">!</span> <span class="nb">echo</span> <span class="s2">"</span><span class="nv">$plist</span><span class="s2">"</span> | egrep <span class="nt">-q</span> <span class="s2">"^.*Library.*/(</span><span class="nv">$pattern</span><span class="s2">)"</span><span class="p">;</span> <span class="k">then
      </span><span class="nb">local </span><span class="nv">plist_name</span><span class="o">=</span><span class="si">$(</span><span class="nb">basename</span> <span class="s2">"</span><span class="nv">$plist</span><span class="s2">"</span> .plist<span class="si">)</span>
      <span class="k">if</span> <span class="o">[[</span> <span class="nv">$directory</span> <span class="o">=</span> /Library<span class="k">*</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
        </span><span class="nb">echo</span> <span class="s2">"sudo bash -c 'launchctl disable system/</span><span class="nv">$plist_name</span><span class="s2"> &amp;&amp; true &gt; </span><span class="se">\"</span><span class="nv">$plist</span><span class="se">\"</span><span class="s2"> &amp;&amp; chmod a-wx </span><span class="se">\"</span><span class="nv">$plist</span><span class="se">\"</span><span class="s2">'"</span>
      <span class="k">else
        </span><span class="nb">echo</span> <span class="s2">"bash -c 'launchctl disable gui/</span><span class="si">$(</span><span class="nb">id</span> <span class="nt">-u</span><span class="si">)</span><span class="s2">/</span><span class="nv">$plist_name</span><span class="s2"> &amp;&amp; true &gt; </span><span class="se">\"</span><span class="nv">$plist</span><span class="se">\"</span><span class="s2"> &amp;&amp; chmod a-wx </span><span class="se">\"</span><span class="nv">$plist</span><span class="se">\"</span><span class="s2">'"</span>
      <span class="k">fi
    fi
  done</span>
<span class="o">}</span>

<span class="c"># Define global and local agents to manage</span>
<span class="nv">global_agents</span><span class="o">=(</span>
  at.obdev.littlesnitch         <span class="c"># Little Snitch</span>
  com.docker                    <span class="c"># Docker</span>
<span class="o">)</span>
process_agents <span class="s2">"/Library"</span> <span class="s2">"</span><span class="k">${</span><span class="nv">global_agents</span><span class="p">[@]</span><span class="k">}</span><span class="s2">"</span>

<span class="nv">local_agents</span><span class="o">=(</span>
  homebrew.mxcl.ollama        <span class="c"># ollama</span>
  jp.plentycom.boa.SteerMouse <span class="c"># SteerMouse</span>
<span class="o">)</span>
process_agents <span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/Library"</span> <span class="s2">"</span><span class="k">${</span><span class="nv">local_agents</span><span class="p">[@]</span><span class="k">}</span><span class="s2">"</span>

<span class="c"># vim: filetype=bash:</span>
</code></pre></div></div>]]></content><author><name>Thomas Witt</name></author><category term="tech" /><summary type="html"><![CDATA[Many macOS software installers add unnecessary background processes, such as auto-updaters, which can be intrusive or even spyware. These “helpers” typically install in the directories: LaunchAgents, LaunchDaemons, or PrivilegedHelperTools, found in /Library or $HOME/Library. To find and potentially remove them, you have to check these locations regularly.]]></summary></entry><entry><title type="html">Investing in Supernova</title><link href="https://www.thomas-witt.com/blog/investing-in-supernova/" rel="alternate" type="text/html" title="Investing in Supernova" /><published>2022-11-16T00:00:00+00:00</published><updated>2025-04-15T17:18:53+00:00</updated><id>https://www.thomas-witt.com/blog/investing-in-supernova</id><content type="html" xml:base="https://www.thomas-witt.com/blog/investing-in-supernova/"><![CDATA[<p>At Expedite Ventures, we love DevTools. We especially love DevTools which
promote cross-functionality by bringing developers together with other creative
colleagues.</p>

<p>And this is exactly what our portfolio company
<a href="https://supernova.io/">Supernova</a> does — in this case it’s bridging the gap
between designers and developers. The connection between those two is an
extremely important one in the world of ever-more important UX — and still, the
disconnect sometimes couldn’t be bigger, particularly in larger organizations.
And that leads to broken, inconsistent user experiences — because it’s simply
hard to manage and update a consistent design over its complete lifecycle.</p>

<p>Supernova actually helps designers and developers to work better together in the
context of a so-called design system. Those have become the hot topic in the
recent years. A design system is a shared language, a set of standards to create
beautiful visual experiences, based on reusable components and patterns. It’s
the final source of through about the design language of a company. At scale.</p>

<p>Supernova helps managing and documenting the entire lifecycle of a Design System
centralized in one place without changing workflows or tools like Figma.</p>

<p>For a developer that means if a refinement of a design is to be rolled out,
you’ll get an automated export delivery of tokens, styles, assets and code to
handover to the developers. So your flutter-based mobile apps will get the same
updates like your website, and developers get everything served on a silver
plate in form of a GitHub pull request. Cool, eh?</p>

<p>And that kind of cooperation doesn’t only make the designers and developers
happy, but also the management of a company. Because they get not only higher
quality through visual consistency across multiple products and channels,
they’ll also save a ton of money. That’s why they keep on convincing more and
more large name brands. And with their latest
<a href="https://www.supernova.io/design-tokens">announcement</a> of their design token
manager with support for Figma Tokens plugin and themes, they’ll continue to
lead the space for Design Systems.</p>

<p>That’s why we are incredibly excited and honored to partner with the Supernova
in their $4.8m seed round. Congratulations to Jiri, the Founder and the whole
team. We are looking forward to the journey ahead!</p>

<p>Of course, we at Expedite will continue to keep investing in great DevTools to
make developers and companies more productive and successful.</p>

<p>Further Reading:</p>

<ul>
  <li><a href="https://www.supernova.io/">Supernova.io Homepage</a></li>
  <li><a href="https://www.supernova.io/blog/supernovas-seed-round-and-our-new-design-token-manager">Blog post by Jiri, Founder of Supernova</a></li>
  <li><a href="https://techcrunch.com/2022/11/16/supernova-wants-to-make-it-easy-to-transfer-design-changes-to-code-bases/">TechCrunch: Supernova wants to make it easy to move design elements to code bases</a></li>
</ul>]]></content><author><name>Thomas Witt</name></author><category term="investment" /><summary type="html"><![CDATA[At Expedite Ventures, we love DevTools. We especially love DevTools which promote cross-functionality by bringing developers together with other creative colleagues.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.thomas-witt.com/assets/images/posts/2022-11-16-supernova.jpeg" /><media:content medium="image" url="https://www.thomas-witt.com/assets/images/posts/2022-11-16-supernova.jpeg" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>