<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nicolas Fränkel</title>
    <description>The latest articles on DEV Community by Nicolas Fränkel (@nfrankel).</description>
    <link>https://dev.to/nfrankel</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F362557%2F479d9637-2db0-4b0a-8070-edbe538c4180.jpg</url>
      <title>DEV Community: Nicolas Fränkel</title>
      <link>https://dev.to/nfrankel</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nfrankel"/>
    <language>en</language>
    <item>
      <title>AI-assisted genealogy</title>
      <dc:creator>Nicolas Fränkel</dc:creator>
      <pubDate>Thu, 03 Sep 2026 09:02:00 +0000</pubDate>
      <link>https://dev.to/nfrankel/ai-assisted-genealogy-9cn</link>
      <guid>https://dev.to/nfrankel/ai-assisted-genealogy-9cn</guid>
      <description>&lt;p&gt;My son recently came to me to brag about using AI to find our ancestors. While the results were correct, I didn't learn anything new, as it stopped at my grandparents. I was never very interested in my genealogy, but I decided to see if AI would be a good tool for this. TL;DR: Yes, it is, and even more than that. In a little less than one month, I managed to gather more than 600 individuals and get back 12 generations in some branches.&lt;/p&gt;

&lt;p&gt;In this post, I want to explain how I did it so that you can do the same.&lt;/p&gt;

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

&lt;p&gt;I decided to handle the project like any other software project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;git&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;git&lt;/code&gt; provider. I'm using both Codeberg and GitHub. See below for the rationale.&lt;/li&gt;
&lt;li&gt;Cloudflare Pages to visualize the tree.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I searched a bit about how to store data and found the &lt;code&gt;GEDCOM&lt;/code&gt; format specification:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The word GEDCOM is an acronym for GEnealogical Data Communication. GEDCOM was created by The Church of Jesus Christ of Latter-day Saints as the specification for exchanging genealogical data between different family tree software products. The original GEDCOM specification was released in 1984, and the last update to it was version 5.5 in 1996 with an incremental upgrade to version 5.5.1 released in 2019.&lt;/p&gt;

&lt;p&gt;—- &lt;a href="https://gedcom.io/about/" rel="noopener noreferrer"&gt;About GEDCOM&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The latest 5.x specification version is 5.5.1, re-released as a standard in 2019. FamilySearch rebooted the specification in 2021 to create v7.0, which is still maintained today. Few tools are actually compatible with it. For this reason, I limited myself to using 5.5.1.&lt;/p&gt;

&lt;h2&gt;
  
  
  GEDCOM 101
&lt;/h2&gt;

&lt;p&gt;GEDCOM is a structured format, based on indentation level. The indentation is not space-based, but relies on a leading number. It looks something like this (spaces added for better readability):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 @I1@ INDI                                   # 1
  1 NAME John Martin /Doe/                    # 2
  1 SEX M
  1 BIRT
    2 DATE 1 JAN 1970
    2 PLAC Paris, 8e, Paris, France
    2 SOUR @S1@                               # 3
      3 PAGE Birth certificate EA68410
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;The base entity is the &lt;code&gt;INDI&lt;/code&gt;, an individual&lt;/li&gt;
&lt;li&gt;Last name is defined between slashes&lt;/li&gt;
&lt;li&gt;Source with ID&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;As I mentioned, I added extra spaces. The following snippet is the correct format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 @I1@ INDI
1 NAME John Martin /Doe/
1 SEX M
1 BIRT
2 DATE 1 JAN 1970
2 PLAC Paris, 8e, Paris, France
2 SOUR @S1@
3 PAGE Birth certificate EA68410
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The loop
&lt;/h2&gt;

&lt;p&gt;The process is always the same.&lt;/p&gt;

&lt;p&gt;Pick someone in the tree with unknown parents, and let the assistant do the search. It knows which genealogy sites to browse, and which archives hold the civil records for the place. When it finds the act, it transcribes it, adds the new individuals to the GEDCOM file, links them to their child, and records the source. Then it commits.&lt;/p&gt;

&lt;p&gt;Rinse and repeat. Each loop adds one generation on one branch, until no records are found.&lt;/p&gt;

&lt;h2&gt;
  
  
  What worked with the assistant
&lt;/h2&gt;

&lt;p&gt;Most AI-related advice also applies to professional projects.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Switch model according to the task&lt;/strong&gt;: Your token quota is limited by nature, unless you pay per token (but in this case, you're rich and tokens aren't an issue). It's not efficient to use super powerful models such as Fable 5 to send emails. However, it's equally inefficient to use a weak model for complex multi-step tasks: you will probably get wrong results.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create skills&lt;/strong&gt;: When you start either repeating the same instructions or notice that the assistant does come to the same conclusion over and over, it's time to create a &lt;a href="https://agentskills.io/home" rel="noopener noreferrer"&gt;skill&lt;/a&gt;. Skills are reusable sets of instructions, scripts, assets, and references. However, the biggest benefit of skills is that they don't bloat the context by default: they are triggered if their description matches the prompt. Skills can be orthogonal.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I have created several skills:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;more than a couple to know how to navigate a site&lt;/li&gt;
&lt;li&gt;one to transcribe record images into plain text (see below)&lt;/li&gt;
&lt;li&gt;two for autonomous sessions (see below)

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Transcribe records into plain text&lt;/strong&gt;: All records you will get will either be PDFs or images. PDFs are hard to parse, and images even more so. In the long run, it pays to parse and transcribe them once, so they can be reused cheaply afterwards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The assistant can make mistakes&lt;/strong&gt;: It does the research, so its mistakes are research mistakes. It may misread a name on a scan, or pick the wrong person when two of them share a first name, a surname, and a village. However, my experience is that Claude Code is pretty good on transcriptions, and it's cautious by default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set up grammar checks&lt;/strong&gt;: The bigger the GEDCOM file, the more chances the assistant will break it at some point. If you notice it after many commits, it will be a mess to straighten it back. Make sure to check the GEDCOM structure at every commit. Either use local pre-commit hooks or a remote job on GitHub/GitLab/whatever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long-running autonomous tasks&lt;/strong&gt;: After a while, I got frustrated waiting for my quota to reset, and treated myself to Claude Max. I now had a gazillion tokens to use. It was not long before I started to run sessions overnight or while at work. If you are in this case, you need to come up with a strong structure so you don't find out at the end of the whole session that you have drifted.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I found out that my sweet spot was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A subagent to avoid polluting the main agent's context&lt;/li&gt;
&lt;li&gt;Serial subagents, not parallel, since I don't have a deadline, and it avoids facing the quota limit mid-month&lt;/li&gt;
&lt;li&gt;The subagent works, commits in a worktree branch, the main agent checks and merges&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I learned about genealogy
&lt;/h2&gt;

&lt;p&gt;Here are the things I learned. For genealogy experts, they may seem obvious. However, if you're a beginner, I hope they might be helpful.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Different countries have different potential&lt;/strong&gt;: Depending on the country, you may get widely different results with the same process. Results may even differ for places inside the same country.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For this reason, and depending on where your ancestors stem from, the tree will probably be unbalanced. In some cases, it may even be quite limited.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A lot of data is already there&lt;/strong&gt;: There are plenty of genealogy websites around. All of these I stumbled upon have a free tier. If you can find a common ancestor with someone who already did the work upwards for you, why not use it?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Work of others can be wrong&lt;/strong&gt;: While you will populate your tree very fast with third-party work, don't take it as proof. You need to verify the facts with a legitimate administrative certificate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the record, I stumbled upon a collateral family member who supposedly died in Switzerland. Since he was the only one, I spent a bit of time trying to find his death certificate, to no avail. It turns out that he died in France in a hunting accident, but next to the border. It made the news in a local Swiss newspaper, and the person inferred that he had died there. Trust, but verify.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;People are genuinely trying to help&lt;/strong&gt;: Everyone I have contacted on these sites has answered. Everyone tried to help. In the worst case, I got nothing that I could use. In the best case, actually two, I got connected to historians who wrote books on the subject.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Birth certificates help push boundaries&lt;/strong&gt;: Birth certificates mention the parents and their own birth date and place. If you can loop over several times, you will get a few generations in no time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Marriage certificates are the next best thing. You'll find the groom and bride's parents' names, but will probably miss their own birth data.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;French marriages usually happened at the bride's parish&lt;/strong&gt;: I had never heard of this tradition, but Claude Code apparently did. In the past, for marriages happening between people from different towns, the tradition was to hold it at the bride's parish.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treat it as a heuristic rather than a rule. I had a couple of cases when my ancestors didn't follow the tradition.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;French civil acts are public 75 years after the event's date&lt;/strong&gt;: In France, if you want a civil act, you need to prove you're either the person in the act or a direct ancestor. But that rule is removed if the act dates back 75 years or more. You can actually access the birth certificate of public figures such as Général de Gaulle this way, and search for their ancestors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In small hamlets, everyone had the same surname&lt;/strong&gt;: Because some of them also had the same first name, they had to add a discriminant. The discriminant was fortunately sometimes written in civil records. When it wasn't, it got extremely confusing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Savoy is a good example of such surname things. They added a second surname there. You can read more about it &lt;a href="https://www.marmottesdesavoie.fr/index.php?page=patro" rel="noopener noreferrer"&gt;here&lt;/a&gt; (in French).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GEDCOM allows defining a place format&lt;/strong&gt;: I came to know about GEDCOM by working on the project, so the TIL items are a very long list. However, I think this one takes first place.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Different countries have different location structures. GEDCOM has a powerful &lt;strong&gt;and&lt;/strong&gt; flexible scheme for locations, which allows defining the format, then the value.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  2 PLAC Lyon, 7e, Rhône, France
  3 FORM Ville, Arrondissement, Département, Pays
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Visualization with Topola on Cloudflare Pages
&lt;/h2&gt;

&lt;p&gt;The more people you add to your tree, the harder it is to visualize the GEDCOM model in your head. I searched for a couple of visualization solutions and found:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://gramps-project.org/" rel="noopener noreferrer"&gt;Gramps&lt;/a&gt;: It's a desktop-based application, available for &lt;a href="https://gramps-project.org/wiki/index.php/Download" rel="noopener noreferrer"&gt;many different platforms&lt;/a&gt;. It works pretty well, but its internal storage is SQLite. Though you can import GEDCOM, you need to clear the existing data first: you can't override them.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/PeWu/topola-viewer" rel="noopener noreferrer"&gt;Topola Genealogy Viewer&lt;/a&gt;: Topola provides an &lt;a href="https://pewu.github.io/topola-viewer/" rel="noopener noreferrer"&gt;interactive website&lt;/a&gt; for you to display a GEDCOM 5.5.1 file. It's the software I chose.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Topola also allows you to display the tree from one single static GEDCOM file. You can use it in a build process, &lt;em&gt;e.g.&lt;/em&gt;, a GitHub workflow, to generate the visualization website. The tree holds data about people who are still alive. I keep it private for this reason. GitHub Pages are public by default, so I didn't want to go this route.&lt;/p&gt;

&lt;p&gt;I checked for options and found &lt;a href="https://pages.cloudflare.com/" rel="noopener noreferrer"&gt;Cloudflare Pages&lt;/a&gt;. It's the first time I've used it, and it's amazing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It integrates natively with GitHub&lt;/li&gt;
&lt;li&gt;You can configure authentication. The easiest one is OTP on a list of allowed emails you configure.&lt;/li&gt;
&lt;li&gt;It creates a subdomain for each branch, so you can preview changes!&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The only downside is a limit of 500 builds a month, more than enough for a free offer and a hobby project.&lt;/p&gt;

&lt;p&gt;This is also the reason for the two &lt;code&gt;git&lt;/code&gt; providers. Codeberg hosts the repository I work on. GitHub only holds a mirror, because Cloudflare Pages builds from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;I never thought about genealogy before. However, it's a lot of fun, and I'm hooked on it now. In one month, the assistant got me what some people spend years searching for. I hope this post gave you ideas and set you on the path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;To go further:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.gedcom.org" rel="noopener noreferrer"&gt;GEDCOM The Genealogy Data Standard&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://gedcom.io/specs/" rel="noopener noreferrer"&gt;Later GEDCOM specifications&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://agentskills.io/home" rel="noopener noreferrer"&gt;Agent Skills&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://gramps-project.org/" rel="noopener noreferrer"&gt;Gramps&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/PeWu/topola-viewer" rel="noopener noreferrer"&gt;Topola Genealogy Viewer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pages.cloudflare.com/" rel="noopener noreferrer"&gt;Cloudflare Pages&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;





&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://blog.frankel.ch/ai-assisted-genealogy/" rel="noopener noreferrer"&gt;A Java Geek&lt;/a&gt; on August 30&lt;sup&gt;th&lt;/sup&gt;, 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>codingassistant</category>
      <category>genealogy</category>
      <category>claude</category>
    </item>
    <item>
      <title>Solving Gradle metadata and Renovate integration</title>
      <dc:creator>Nicolas Fränkel</dc:creator>
      <pubDate>Thu, 20 Aug 2026 09:02:00 +0000</pubDate>
      <link>https://dev.to/nfrankel/solving-gradle-metadata-and-renovate-integration-2d4a</link>
      <guid>https://dev.to/nfrankel/solving-gradle-metadata-and-renovate-integration-2d4a</guid>
      <description>&lt;p&gt;My current company has settled on using Gradle. It doesn't make me &lt;a href="https://blog.frankel.ch/final-take-gradle/" rel="noopener noreferrer"&gt;very happy&lt;/a&gt;, but you need to learn to work with constraints. Plus, I must admit that the developers who actually implemented the build files did a pretty good job overall: they used Kotlin instead of Groovy, they moved code to regular plugins, etc.&lt;/p&gt;

&lt;p&gt;This week, I worked on improvements to a new project and set up Renovate. Renovate is similar to Dependabot in that it checks for new versions of your dependencies and automatically creates PRs for you. However, I quickly noticed that merges of new dependency versions failed the build. Here's the full story on why, and how I fixed the issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Supply chain hardening
&lt;/h2&gt;

&lt;p&gt;In the last few years, one of the big issues in the IT world has been supply chain attacks. Depending on others' work isn't a new thing:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The woolen coat, for example, which covers the day-labourer, as coarse and rough as it may appear, is the produce of the joint labour of a great multitude of workmen.&lt;/p&gt;

&lt;p&gt;—- Adam Smith, The Wealth of Nations&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The software world has increased the phenomenon a lot due to two factors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Digitized assets make the reproduction cost-free. You can distribute as many copies as you want.&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Open Source has created many projects that serve as foundations for others, from full-fledged operating systems to specialized libraries, &lt;em&gt;e.g.&lt;/em&gt;, &lt;code&gt;curl&lt;/code&gt;. Mandatory illustration:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh6a6f560xn8dvmhcpu1o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh6a6f560xn8dvmhcpu1o.png" alt="xkcd Dependency comic: all modern digital infrastructure rests on a project some random person in Nebraska has thanklessly maintained since 2003"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For hackers, it creates an interesting attack vector:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Working with external dependencies and plugins from third-party repositories exposes your build to significant supply chain risks. Dependencies are the most commonly attacked part of the software supply chain, and every artifact you consume, including transitively pulled-in binaries, needs to be both legitimate and unchanged.&lt;/p&gt;

&lt;p&gt;To illustrate the risk, consider building an application that uses the &lt;code&gt;trusty-lib:1.0&lt;/code&gt; library from a public repository. If an attacker successfully replaces &lt;code&gt;trusty-lib&lt;/code&gt; in the repository with a malicious version having the same coordinates (&lt;code&gt;trusty-lib:1.0&lt;/code&gt;), your next build will download the compromised code without any warning.&lt;/p&gt;

&lt;p&gt;—- &lt;a href="https://docs.gradle.org/current/userguide/dependency_verification.html" rel="noopener noreferrer"&gt;Verifying Dependencies&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This type of attack is known as a &lt;strong&gt;supply chain attack&lt;/strong&gt;. In recent years, there have been more than a couple of such attacks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying dependencies
&lt;/h2&gt;

&lt;p&gt;The Java platform introduced dependency verification via JAR signing in early versions. It was present at the latest in &lt;a href="https://web.pa.msu.edu/reference/jdk-1.2.2-docs/tooldocs/win32/jarsigner.html" rel="noopener noreferrer"&gt;1.2&lt;/a&gt;. You can check the &lt;a href="https://docs.oracle.com/javase/tutorial/deployment/jar/signing.html" rel="noopener noreferrer"&gt;process documentation&lt;/a&gt; if you're interested. In short, a signed JAR has specific files in its &lt;code&gt;META-INF&lt;/code&gt; folder.&lt;/p&gt;

&lt;p&gt;JAR signing never became popular, and I can only assume the reasons why:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The process was (and still is) complicated&lt;/li&gt;
&lt;li&gt;DevOps didn't exist&lt;/li&gt;
&lt;li&gt;Certificates were expensive&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Over JARs carrying their signature files, people preferred a weaker form of verification: no signature, but a short fingerprint of the file itself. This guarantees &lt;strong&gt;integrity&lt;/strong&gt;. Integrity means you're downloading the exact file that you intend to.&lt;/p&gt;

&lt;p&gt;Integrity verification is widespread because it's simple. You first download the file. Then, you run a dedicated application on the file to generate its hash. If the generated hash and the advertised hash match, the file is the original file; if not, it has been tampered with.&lt;/p&gt;

&lt;p&gt;Maven Central provides such a hash for each artifact it offers for download. For example, here's the &lt;a href="https://repo1.maven.org/maven2/org/apache/logging/log4j/log4j-slf4j2-impl/2.26.1/" rel="noopener noreferrer"&gt;download page&lt;/a&gt; for the Log4J2 SLF4J bridge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;log4j-slf4j2-impl-2.26.1-cyclonedx.xml            2026-06-29 07:47     26067      
log4j-slf4j2-impl-2.26.1-cyclonedx.xml.asc        2026-06-29 07:47       856      
log4j-slf4j2-impl-2.26.1-cyclonedx.xml.md5        2026-06-29 07:47        32      
log4j-slf4j2-impl-2.26.1-cyclonedx.xml.sha1       2026-06-29 07:47        40      
log4j-slf4j2-impl-2.26.1-sources.jar              2026-06-29 07:47     23459      
log4j-slf4j2-impl-2.26.1-sources.jar.asc          2026-06-29 07:47       856      
log4j-slf4j2-impl-2.26.1-sources.jar.md5          2026-06-29 07:47        32      
log4j-slf4j2-impl-2.26.1-sources.jar.sha1         2026-06-29 07:47        40      
log4j-slf4j2-impl-2.26.1.jar                      2026-06-29 07:47     30212      
log4j-slf4j2-impl-2.26.1.jar.asc                  2026-06-29 07:47       856      
log4j-slf4j2-impl-2.26.1.jar.md5                  2026-06-29 07:47        32      
log4j-slf4j2-impl-2.26.1.jar.sha1                 2026-06-29 07:47        40      
log4j-slf4j2-impl-2.26.1.module                   2026-06-29 07:47      3524      
log4j-slf4j2-impl-2.26.1.module.asc               2026-06-29 07:47       856      
log4j-slf4j2-impl-2.26.1.module.md5               2026-06-29 07:47        32      
log4j-slf4j2-impl-2.26.1.module.sha1              2026-06-29 07:47        40      
log4j-slf4j2-impl-2.26.1.pom                      2026-06-29 07:47      5141      
log4j-slf4j2-impl-2.26.1.pom.asc                  2026-06-29 07:47       856      
log4j-slf4j2-impl-2.26.1.pom.md5                  2026-06-29 07:47        32      
log4j-slf4j2-impl-2.26.1.pom.sha1                 2026-06-29 07:47        40      
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the three associated files for each published file:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;.asc&lt;/code&gt;: a &lt;a href="https://www.gnupg.org/" rel="noopener noreferrer"&gt;GPG&lt;/a&gt; &lt;em&gt;signature&lt;/em&gt; file&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.md5&lt;/code&gt;: a MD5 hash&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.sha1&lt;/code&gt;: a SHA1 hash&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;While Maven Central provides these files, it is up to consumers to verify the integrity (or authenticity) of the downloaded files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gradle verification
&lt;/h2&gt;

&lt;p&gt;Gradle does help with signature or hash verification by automating the process. During the build process, it verifies the integrity or the authenticity of dependencies. To generate the file, run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./gradlew &lt;span class="nt"&gt;--write-verification-metadata&lt;/span&gt; sha256,pgp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It creates an XML file similar to the following (sample taken from the documentation):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;verification-metadata&lt;/span&gt;
  &lt;span class="na"&gt;xmlns=&lt;/span&gt;&lt;span class="s"&gt;"https://schema.gradle.org/dependency-verification"&lt;/span&gt;
  &lt;span class="na"&gt;xmlns:xsi=&lt;/span&gt;&lt;span class="s"&gt;"http://www.w3.org/2001/XMLSchema-instance"&lt;/span&gt;
  &lt;span class="na"&gt;xsi:schemaLocation=&lt;/span&gt;&lt;span class="s"&gt;"https://schema.gradle.org/dependency-verification
                      https://schema.gradle.org/dependency-verification/dependency-verification-1.4.xsd"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;components&amp;gt;&lt;/span&gt;
    &lt;span class="c"&gt;&amp;lt;!-- BOTH SIGNATURE AND CHECKSUM --&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;component&lt;/span&gt; &lt;span class="na"&gt;group=&lt;/span&gt;&lt;span class="s"&gt;"com.google.guava"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"failureaccess"&lt;/span&gt; &lt;span class="na"&gt;version=&lt;/span&gt;&lt;span class="s"&gt;"1.0.3"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;artifact&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"failureaccess-1.0.3.jar"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;pgp&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"BDB5FA4FE719D787FB3D3197F6D4A1D411E9D1AE"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;sha256&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"cbfc3906b19b8f55dd7cfd6dfe0aa4532e834250d7f080bd8d211a3e246b59cb"&lt;/span&gt;
            &lt;span class="na"&gt;origin=&lt;/span&gt;&lt;span class="s"&gt;"Verified"&lt;/span&gt;
            &lt;span class="na"&gt;reason=&lt;/span&gt;&lt;span class="s"&gt;"Added manually to fix CI"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/artifact&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/component&amp;gt;&lt;/span&gt;
    &lt;span class="c"&gt;&amp;lt;!-- CHECKSUM ONLY --&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;component&lt;/span&gt; &lt;span class="na"&gt;group=&lt;/span&gt;&lt;span class="s"&gt;"antlr"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"antlr"&lt;/span&gt; &lt;span class="na"&gt;version=&lt;/span&gt;&lt;span class="s"&gt;"2.7.7"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;artifact&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"antlr-2.7.7.jar"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;sha256&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"88fbda4b912596b9f56e8e12e580cc954bacfb51776ecfddd3e18fc1cf56dc4c"&lt;/span&gt;
            &lt;span class="na"&gt;origin=&lt;/span&gt;&lt;span class="s"&gt;"Verified"&lt;/span&gt;
            &lt;span class="na"&gt;reason=&lt;/span&gt;&lt;span class="s"&gt;"Artifact is not signed"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/artifact&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/component&amp;gt;&lt;/span&gt;
    &lt;span class="c"&gt;&amp;lt;!-- SIGNATURE ONLY --&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;component&lt;/span&gt; &lt;span class="na"&gt;group=&lt;/span&gt;&lt;span class="s"&gt;"com.beust"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"jcommander"&lt;/span&gt; &lt;span class="na"&gt;version=&lt;/span&gt;&lt;span class="s"&gt;"1.78"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;artifact&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"jcommander-1.78.jar"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;pgp&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"C70B844F002F21F6D2B9C87522E44AC0622B91C3"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;pgp&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"DCBA03381EF6C89096ACD985AC5EC74981F9CDA6"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/artifact&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/component&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/components&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/verification-metadata&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gradle compares dependencies' hash or signature against data in the file above.&lt;/p&gt;

&lt;p&gt;Note that the process is not foolproof: if the library has already been compromised, the command will write the hash/signature of the compromised library. Gradle's documentation doesn't try to sugarcoat it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Gradle can automatically generate a dependency verification file by downloading all your dependencies and recording their checksums or signatures. This is called bootstrapping.&lt;/p&gt;

&lt;p&gt;However, bootstrapping has a critical security limitation: it trusts whatever is currently in your repositories. If a malicious dependency has already been introduced into your build (or into a repository you use), Gradle will record the compromised artifact's checksum or signature.&lt;/p&gt;

&lt;p&gt;This is why you must review the generated verification file. Bootstrapping is convenient for getting started or updating your verification file, but it's not a substitute for manual verification of critical dependencies.&lt;/p&gt;

&lt;p&gt;—- &lt;a href="https://docs.gradle.org/current/userguide/dependency_verification.html#sec:bootstrapping-verification" rel="noopener noreferrer"&gt;Generating and Bootstrapping Verification Metadata&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;However, once you have bootstrapped the metadata file with safer dependencies, Gradle guarantees the same dependencies will be used in other environments, including CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  The issue with Renovate and its fix
&lt;/h2&gt;

&lt;p&gt;So far, I have explained how Gradle verifies dependencies and how useful it is. It's time to introduce Renovate and the issue I faced.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Renovate opened a PR.&lt;/li&gt;
&lt;li&gt;I merged it.&lt;/li&gt;
&lt;li&gt;It broke the build, and I had to manually regenerate the metadata file in a new PR.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I wrongly thought that Gradle only verified artifacts listed in the metadata file, and that a new, unlisted dependency version would pass. In fact, the build breaks in case of new dependency version pairs that aren't listed in the metadata. It makes sense, as otherwise, the metadata file would soon be made irrelevant.&lt;/p&gt;

&lt;p&gt;I considered several solutions, including discarding Renovate or Gradle metadata. However, I finally settled on &lt;code&gt;postUpgradeTasks.commands&lt;/code&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A list of commands that are executed after Renovate has updated a dependency but before the commit is made.&lt;/p&gt;

&lt;p&gt;—- &lt;a href="https://docs.renovatebot.com/configuration-options/#postupgradetaskscommands" rel="noopener noreferrer"&gt;postUpgradeTasks.commands&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We can generate the metadata file again by using this directive.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"$schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://docs.renovatebot.com/renovate-schema.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;                 &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"postUpgradeTasks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"commands"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="s2"&gt;"./gradlew --write-verification-metadata pgp,sha256 --refresh-keys --export-keys dependencies"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"fileFilters"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="s2"&gt;"gradle/verification-metadata.xml"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;                                     &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="s2"&gt;"gradle/verification-keyring.gpg"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;                                      &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="s2"&gt;"gradle/verification-keyring.keys"&lt;/span&gt;&lt;span class="w"&gt;                                      &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"installTools"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"java"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt;                                                              &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Standard JSON schema&lt;/li&gt;
&lt;li&gt;The exact command to run&lt;/li&gt;
&lt;li&gt;Renovate is only allowed to update these files&lt;/li&gt;
&lt;li&gt;Renovate runs in a dedicated container, without Java. Since Gradle relies on a JDK, you need to install it first.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now, every Renovate PR for a Gradle dependency will regenerate the metadata file. It's not a perfect solution, though. The warning about the initial bootstrap still stands: if the downloaded dependency is compromised, the metadata is generated with the wrong hash and signature. I think that's the price to pay.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;I started using Renovate on projects I didn't initiate. It allows me to learn about its issues and how to fix them. Renovate's &lt;a href="https://docs.renovatebot.com/configuration-options" rel="noopener noreferrer"&gt;configuration options&lt;/a&gt; page has become my new friend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;To go further:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.oracle.com/javase/tutorial/deployment/jar/signing.html" rel="noopener noreferrer"&gt;Signing JAR Files&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.gradle.org/current/userguide/dependency_verification.html" rel="noopener noreferrer"&gt;Understanding Verification Methods&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.renovatebot.com/configuration-options/#postupgradetasksinstalltools" rel="noopener noreferrer"&gt;Renovate configuration options&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;





&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://blog.frankel.ch/gradle-metadata-renovate-integration/" rel="noopener noreferrer"&gt;A Java Geek&lt;/a&gt; on August 16&lt;sup&gt;th&lt;/sup&gt;, 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gradle</category>
      <category>renovate</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>GitHub agentic workflows and Renovate</title>
      <dc:creator>Nicolas Fränkel</dc:creator>
      <pubDate>Thu, 06 Aug 2026 09:02:00 +0000</pubDate>
      <link>https://dev.to/nfrankel/github-agentic-workflows-and-renovate-40i4</link>
      <guid>https://dev.to/nfrankel/github-agentic-workflows-and-renovate-40i4</guid>
      <description>&lt;p&gt;I've been a big fan of Renovate for &lt;a href="https://blog.frankel.ch/renovate-alternative-dependabot/" rel="noopener noreferrer"&gt;a couple of years already&lt;/a&gt;. Renovate scans your repositories, detects outdated package versions, and opens pull requests to automatically bump them. It's similar to Dependabot in that it keeps your dependencies up to date. If I had to compare them in one sentence, I'd say Renovate is less integrated in the GitHub ecosystem, but handles more ecosystems and, more importantly, is extensible. My current company is a happy Renovate user, and I already started to use it in some repositories.&lt;/p&gt;

&lt;p&gt;I have previously written about &lt;a href="https://blog.frankel.ch/agentic-github-workflows/" rel="noopener noreferrer"&gt;Agentic Workflows&lt;/a&gt;. As a reminder, you first create the workflow in Markdown with a very loose syntax. Then, you "compile" it to generate a GitHub Workflow-compliant YAML file, suffixed with &lt;code&gt;.lock.yml&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In this post, I want to describe the problem when combining the two, how I tried to fix it, and the correct solution.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core problem
&lt;/h2&gt;

&lt;p&gt;Renovate is very eager to please. By default, it scans all ecosystems used for dependencies: &lt;code&gt;build.gradle.kts&lt;/code&gt;, &lt;code&gt;pom.xml&lt;/code&gt;, &lt;code&gt;pyproject.toml&lt;/code&gt;, &lt;code&gt;Dockerfile&lt;/code&gt;, GitHub workflows, etc. Most "manual" GitHub workflows use GitHub actions, which are versioned dependencies.&lt;/p&gt;

&lt;p&gt;The generated lock of agentic workflows does make use of GitHub actions, in particular &lt;code&gt;gh-aw-actions/setup&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When Renovate worked on my repository, it did what it always does. It searched for candidate files, found my documentation-review workflow’s lock file, scanned the dependencies, and opened a PR bumping the SHA to the latest, including &lt;code&gt;gh-aw-actions/setup&lt;/code&gt;. So far, so good.&lt;/p&gt;

&lt;p&gt;However, it created an interesting problem. The action SHA pointed to a new version of &lt;code&gt;gh-aw-actions&lt;/code&gt;. The script body inside the same file didn't change, but referenced a helper script that the new version didn't provide. The workflow failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wrong fix
&lt;/h2&gt;

&lt;p&gt;I have to admit I didn't understand the above synchronization issue at first.&lt;/p&gt;

&lt;p&gt;To fix it, I created a new non-agentic GitHub workflow. Whenever Renovate upgrades &lt;code&gt;lock.yml&lt;/code&gt;, the workflow automatically recompiles the agentic workflow. This introduced a subtler bug. The trigger fired on &lt;em&gt;any&lt;/em&gt; change to &lt;code&gt;docs-review.lock.yml&lt;/code&gt;, not just &lt;code&gt;gh-aw-actions&lt;/code&gt; version bumps.&lt;/p&gt;

&lt;p&gt;In my case, Renovate later opened a separate PR to pin the Node.js version inside the same file: the recompile workflow triggered, ran &lt;code&gt;gh aw compile&lt;/code&gt;, and overwrote the file, rolling back the node-version change Renovate had just made.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual solution
&lt;/h2&gt;

&lt;p&gt;After the second rollback, I realized something was fishy and understood the root cause. The compiler generates the whole lock file from a single &lt;code&gt;gh-aw&lt;/code&gt; release. The action pin and the inline scripts inside the file must match. Renovate bumped the former and left the latter untouched.&lt;/p&gt;

&lt;p&gt;The solution was much simpler. It consisted of two parts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exclude the lock file from Renovate&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Renovate has an &lt;code&gt;ignorePaths&lt;/code&gt; entry. I added the lock file and &lt;code&gt;agentics-maintenance.yml&lt;/code&gt;, another workflow that the compilation generates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"ignorePaths"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="s2"&gt;".github/workflows/docs-review.lock.yml"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="s2"&gt;".github/workflows/agentics-maintenance.yml"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Update the actual source, then recompile everything&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The real source of version pins is &lt;code&gt;.github/aw/actions-lock.json&lt;/code&gt;, a manifest file that &lt;code&gt;gh aw compile&lt;/code&gt; reads to resolve action SHAs. Renovate can't update this file yet. There's an open &lt;a href="https://github.com/renovatebot/renovate/issues/44753" rel="noopener noreferrer"&gt;feature request&lt;/a&gt; to support it. In the meantime, &lt;code&gt;gh aw update-actions&lt;/code&gt; refreshes the manifest. I kept my recompile workflow but narrowed its trigger to this single file. When the manifest changes, the workflow fires and runs bare &lt;code&gt;gh aw compile&lt;/code&gt;, regenerating all compiled artifacts at once.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;master&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;.github/aw/actions-lock.json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="p"&gt;{ secrets.GITHUB_TOKEN &lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; gh aw compile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://github.github.com/gh-aw/reference/compilation-process/" rel="noopener noreferrer"&gt;&lt;code&gt;gh-aw&lt;/code&gt; documentation&lt;/a&gt; warns against editing generated references by hand: you should run &lt;code&gt;gh aw compile&lt;/code&gt; to regenerate them. It boils down to the same split: the manifest owns the pins, the compiler owns the generated outputs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Generated files are not dependency files, even when they contain version strings. We all know we shouldn't change generated files. New tools shouldn't change this approach.&lt;/p&gt;

&lt;p&gt;In our case, Renovate should only know about the source, not the generated files, even when they are under source control. Ergo, filter out files generated by agentic workflows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;To go further:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.github.com/gh-aw/" rel="noopener noreferrer"&gt;GitHub Agentic Workflows&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.renovatebot.com/configuration-options/#ignorepaths" rel="noopener noreferrer"&gt;ignorePaths&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/renovatebot/renovate/issues/44753" rel="noopener noreferrer"&gt;Add support for the GitHub Agentic Workflows (gh-aw) lockfile&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.github.com/gh-aw/reference/compilation-process/" rel="noopener noreferrer"&gt;Compilation Process&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.github.com/gh-aw/reference/dependabot/" rel="noopener noreferrer"&gt;Dependabot Manifest Generation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;





&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://blog.frankel.ch/github-agentic-workflow-renovate/" rel="noopener noreferrer"&gt;A Java Geek&lt;/a&gt; on August 2&lt;sup&gt;nd&lt;/sup&gt;, 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>github</category>
      <category>agenticworkflow</category>
      <category>renovate</category>
      <category>devops</category>
    </item>
    <item>
      <title>Two nasty surprises in Home Assistant's config</title>
      <dc:creator>Nicolas Fränkel</dc:creator>
      <pubDate>Thu, 09 Jul 2026 07:02:00 +0000</pubDate>
      <link>https://dev.to/nfrankel/two-nasty-surprises-in-home-assistants-config-562e</link>
      <guid>https://dev.to/nfrankel/two-nasty-surprises-in-home-assistants-config-562e</guid>
      <description>&lt;p&gt;Last year, I motorized the rolling shutters on the southern façade of my apartment. My idea was to manage them via Home Assistant. I had a couple of automations in mind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In the evening, roll down the shutters of my bedroom&lt;/li&gt;
&lt;li&gt;In the morning:

&lt;ul&gt;
&lt;li&gt;If it's too hot outside, roll down all shutters&lt;/li&gt;
&lt;li&gt;If it's too cold outside, roll down all shutters&lt;/li&gt;
&lt;li&gt;In other cases, roll up all shutters but my bedroom's&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Living in France, I added the official &lt;a href="https://www.home-assistant.io/integrations/meteo_france/" rel="noopener noreferrer"&gt;Météo France integration&lt;/a&gt;. The integration provides different measurements, including air temperature for my city. Here's the historical data. As you can see, it was pretty hot.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxjnfzfk133042e9k59ff.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxjnfzfk133042e9k59ff.png" alt="Outside temperature graph" width="800" height="403"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I could have used the temperature directly in the automation, but I wanted to define boolean sensors for "too hot" and "too cold" so I could reuse them across different automations. I defined two custom sensors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;binary_sensor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hot&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;outside"&lt;/span&gt;
      &lt;span class="na"&gt;unique_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hot_outside_condition&lt;/span&gt;
      &lt;span class="na"&gt;device_class&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;heat&lt;/span&gt;
      &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="s"&gt;{{ states('sensor.mycity_temperature') | float(0) &amp;gt;= 20 }}&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cold&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;outside"&lt;/span&gt;
      &lt;span class="na"&gt;unique_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cold_outside_condition&lt;/span&gt;
      &lt;span class="na"&gt;device_class&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cold&lt;/span&gt;
      &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="s"&gt;{{ states('sensor.mycity_temperature') | float(0) &amp;lt; 10 }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At this point, you can use the time for the trigger, and a sensor defined for the condition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;alias&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Close all shutters in the morning when it's hot&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Close all shutters if it's already hot in the morning&lt;/span&gt;
  &lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;time&lt;/span&gt;
      &lt;span class="na"&gt;at&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;06:00:00&lt;/span&gt;                               &lt;span class="c1"&gt;# 1&lt;/span&gt;
  &lt;span class="na"&gt;conditions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;state&lt;/span&gt;
      &lt;span class="na"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;binary_sensor.hot_outside&lt;/span&gt;
      &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;on'&lt;/span&gt;
  &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cover.close_cover&lt;/span&gt;
      &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
      &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;cover.volet_salon_gauc_low_speed&lt;/span&gt;     &lt;span class="c1"&gt;# 2&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;cover.middle_shutter_low_speed&lt;/span&gt;       &lt;span class="c1"&gt;# 2&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;cover.s_so_rs100_io_low_speed_3&lt;/span&gt;      &lt;span class="c1"&gt;# 2&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;cover.s_so_rs100_io_low_speed_2&lt;/span&gt;      &lt;span class="c1"&gt;# 2&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;cover.s_so_rs100_io_low_speed&lt;/span&gt;        &lt;span class="c1"&gt;# 2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Time is relative to the timezone of the Home Assistant instance.&lt;/li&gt;
&lt;li&gt;Unrelated, but I noticed that I need to work on my naming scheme&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This year, I woke up earlier than last year. Because I was awake when the automation was supposed to run, I noticed it didn't. After fiddling a bit around, I noticed that the problem was related to the time. The UI showed a weird timezone: Paris/+1. If you're familiar with European time zones, you know about summer time. We move one hour forward during summer, then back again in autumn. However, it showed Paris/+1 instead of Paris/+2, though I was in summer.&lt;/p&gt;

&lt;p&gt;I didn't know if it was a bug or not, but it felt like it was. The trigger time is relative to the instance's timezone, as callout &amp;lt;1&amp;gt; pointed out. Stuck one hour behind, my automation fired exactly on schedule. It was just an hour late compared to the actual time outside. In any case, you can override the timezone in the configuration.&lt;/p&gt;

&lt;p&gt;I did, and immediately lost the connection. It turns out that once you override a single line in the &lt;code&gt;homeassistant&lt;/code&gt; section, you lose all parameters set via the UI. It comprises the temperature unit, the distance metric, but also the URL you can access it. It falls back to the IP.&lt;/p&gt;

&lt;p&gt;The fix is straightforward: set every line again.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;homeassistant&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Home&lt;/span&gt;
  &lt;span class="na"&gt;time_zone&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Europe/Paris&lt;/span&gt;
  &lt;span class="na"&gt;latitude&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;redacted&amp;gt;&lt;/span&gt;
  &lt;span class="na"&gt;longitude&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;redacted&amp;gt;&lt;/span&gt;
  &lt;span class="na"&gt;elevation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;redacted&amp;gt;&lt;/span&gt;
  &lt;span class="na"&gt;radius&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
  &lt;span class="na"&gt;unit_system&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;metric&lt;/span&gt;
  &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;EUR&lt;/span&gt;
  &lt;span class="na"&gt;country&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;FR&lt;/span&gt;
  &lt;span class="na"&gt;internal_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://home.local:8123&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that once you use YAML-based configuration, the whole UI block becomes invalid.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0eljnf0y2m4ho8kepgrt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0eljnf0y2m4ho8kepgrt.png" alt="Home Assistant UI block" width="800" height="843"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After the changes, the automation worked as expected. Still, I shouldn't have to set the timezone manually for summer time to apply correctly. And I definitely shouldn't lose all UI-related settings because of it. Both feel like bugs on Home Assistant's side, and a quick search confirmed they are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Time-based automations can get stuck on the pre-Daylight Saving Time offset until Home Assistant restarts: &lt;a href="https://github.com/home-assistant/core/issues/153175" rel="noopener noreferrer"&gt;issue on core#153175&lt;/a&gt;, closed as not planned&lt;/li&gt;
&lt;li&gt;Setting a single key under &lt;code&gt;homeassistant&lt;/code&gt; disables the whole General settings page in the UI, not just the key set: &lt;a href="https://github.com/home-assistant/frontend/issues/14628" rel="noopener noreferrer"&gt;issue on frontend#14628&lt;/a&gt;, also closed as not planned&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Good luck on your Home Assistant path. I hope the above can be helpful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;To go further:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.home-assistant.io/docs/configuration/" rel="noopener noreferrer"&gt;The configuration.yaml file&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.home-assistant.io/docs/configuration/basic/" rel="noopener noreferrer"&gt;Home information&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.home-assistant.io/integrations/homeassistant/" rel="noopener noreferrer"&gt;Home Assistant Core&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/home-assistant/core/issues/153175" rel="noopener noreferrer"&gt;Times of the day binary sensor out by one hour after switch to daylight saving&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/home-assistant/frontend/issues/14628" rel="noopener noreferrer"&gt;Unable to edit settings - general while a homeassistant: entry is in configuration.yaml&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;





&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://blog.frankel.ch/two-surprises-home-assistant-config/" rel="noopener noreferrer"&gt;A Java Geek&lt;/a&gt; on July 5th, 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>homeassistant</category>
      <category>bug</category>
      <category>locale</category>
      <category>config</category>
    </item>
    <item>
      <title>On programming languages, targets, and platforms</title>
      <dc:creator>Nicolas Fränkel</dc:creator>
      <pubDate>Thu, 25 Jun 2026 09:02:00 +0000</pubDate>
      <link>https://dev.to/nfrankel/on-programming-languages-targets-and-platforms-1g9o</link>
      <guid>https://dev.to/nfrankel/on-programming-languages-targets-and-platforms-1g9o</guid>
      <description>&lt;p&gt;I started as a Java developer, but for some time now, I have broadened my horizons. Recently, I thought about how early languages were dedicated to a single target and platform, and now they are broadening their focus. In this post, I want to write down my thoughts in the hope that it may be useful to others, probably to my future self.&lt;/p&gt;

&lt;h2&gt;
  
  
  Definitions
&lt;/h2&gt;

&lt;p&gt;You may have been wondering about the title terms. I'm pretty sure that if you read this post, you have a pretty good picture of what a programming language is. Some may disagree on some finer points or raise a hair-splitting one, but it's not a PhD thesis, only a post on my blog. I must define what I mean by &lt;strong&gt;target&lt;/strong&gt; and &lt;strong&gt;platform&lt;/strong&gt; in the context of this post before going further.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Target&lt;/strong&gt;&lt;br&gt;
A target only makes sense in the context of compiled programming languages. For example, C's target is &lt;em&gt;native code&lt;/em&gt;, and Java's is &lt;em&gt;bytecode&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Platform&lt;/strong&gt;&lt;br&gt;
A platform is the system that will ultimately run the target. Native code runs on the operating system; bytecode on the JVM.&lt;/p&gt;

&lt;h2&gt;
  
  
  Early programming languages
&lt;/h2&gt;

&lt;p&gt;Early programming languages had a single target and platform. I mentioned C and Java, but Ruby, Python, JavaScript, etc., were all the same.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Programming language&lt;/th&gt;
&lt;th&gt;Target&lt;/th&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;Native code&lt;/td&gt;
&lt;td&gt;Operating system&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C++&lt;/td&gt;
&lt;td&gt;Native code&lt;/td&gt;
&lt;td&gt;Operating system&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Java&lt;/td&gt;
&lt;td&gt;Bytecode&lt;/td&gt;
&lt;td&gt;JVM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;Python runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TypeScript&lt;/td&gt;
&lt;td&gt;JavaScript&lt;/td&gt;
&lt;td&gt;Browser &amp;amp; server-side JS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JavaScript&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;Browser&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I believe it was the case for a long time. It changed at some point, though.&lt;/p&gt;
&lt;h2&gt;
  
  
  Multi-target is the new black
&lt;/h2&gt;

&lt;p&gt;The first time I heard about multi-target was in Scala. Scala came from the era of single-target and targeted bytecode on the JVM platform. However, in 2015, Martin Odersky announced Scala.js, which added JavaScript to Scala's target.&lt;/p&gt;

&lt;p&gt;The original article was published on InfoWorld, but it seems to have redirection issues nowadays. Here's the introduction on a copy:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Scala, developed as a functional and object-oriented language for the JVM, is now multiplatform, with developers using it in abundance on JavaScript via Scala.js, Scala founder Martin Odersky says.&lt;/p&gt;

&lt;p&gt;With Scala.js, developers write code in Scala, and the code is then compiled to JavaScript, analogous to using Microsoft's TypeScript. Developers can leverage their Scala skills in Web development. "[Scala is] very popular on JavaScript now," Odersky said at the Scala Days conference in San Francisco.&lt;/p&gt;

&lt;p&gt;—- &lt;a href="https://goinfotech.blogspot.com/2015/03/scalajs-lets-you-compile-scala-to.html" rel="noopener noreferrer"&gt;Scala.js lets you compile Scala to JavaScript&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;While Scala was the first I had heard about, other languages started to target JavaScript: I know at least Kotlin and Clojure, two originally JVM-bound languages. From that point on, it seemed every language started to add more targets. When they didn't, third parties tried to do it.&lt;/p&gt;

&lt;p&gt;I believe that Kotlin was the epitome of such a strategy. It started with JavaScript, but the team later added native with LLVM and is currently working on WebAssembly, as far as I know. Java developers weren't left behind. The &lt;a href="https://www.graalvm.org/" rel="noopener noreferrer"&gt;GraalVM&lt;/a&gt; project, managed by Oracle, allows them to generate native code. A third-party project, &lt;a href="https://teavm.org/" rel="noopener noreferrer"&gt;TeaVM&lt;/a&gt;, targets JavaScript and WebAssembly. It seems that nowadays lots of languages target Wasm.&lt;/p&gt;

&lt;p&gt;Here's a small excerpt of languages and what targets and platforms they support at the time of this writing. It can't be exhaustive and obviously focuses on the JVM ecosystem, which I happen to know better.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Programming language&lt;/th&gt;
&lt;th&gt;Native/supporting project&lt;/th&gt;
&lt;th&gt;Target&lt;/th&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td rowspan="5"&gt;Java&lt;/td&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;td&gt;Bytecode&lt;/td&gt;
&lt;td&gt;JVM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.graalvm.org/" rel="noopener noreferrer"&gt;GraalVM&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Native code&lt;/td&gt;
&lt;td&gt;Desktop OS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td rowspan="3"&gt;&lt;a href="https://teavm.org/" rel="noopener noreferrer"&gt;TeaVM&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;JavaScript&lt;/td&gt;
&lt;td&gt;Browser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://teavm.org/docs/wasm-gc-backend.html" rel="noopener noreferrer"&gt;WebAssembly&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Wasm runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://teavm.org/docs/c-backend.html" rel="noopener noreferrer"&gt;C&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td rowspan="6"&gt;Kotlin&lt;/td&gt;
&lt;td rowspan="6"&gt;Native&lt;/td&gt;
&lt;td&gt;Bytecode&lt;/td&gt;
&lt;td&gt;JVM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td rowspan="2"&gt;JavaScript&lt;/td&gt;
&lt;td&gt;Browser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server-side runtimes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td rowspan="3"&gt;Native&lt;/td&gt;
&lt;td&gt;Desktop OS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iOS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td rowspan="5"&gt;Dart&lt;/td&gt;
&lt;td rowspan="2"&gt;Native&lt;/td&gt;
&lt;td&gt;JavaScript&lt;/td&gt;
&lt;td&gt;Browser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebAssembly&lt;/td&gt;
&lt;td&gt;Wasm runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td rowspan="3"&gt;&lt;a href="https://github.com/dart-native/dart_native" rel="noopener noreferrer"&gt;DartNative&lt;/a&gt;&lt;/td&gt;
&lt;td rowspan="3"&gt;Native&lt;/td&gt;
&lt;td&gt;Desktop OS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iOS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td rowspan="3"&gt;Rust&lt;/td&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;td&gt;Native code&lt;/td&gt;
&lt;td&gt;Desktop OS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Native via a &lt;code&gt;target&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;WebAssembly&lt;/td&gt;
&lt;td&gt;Wasm runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/IntegralPilot/rustc_codegen_jvm" rel="noopener noreferrer"&gt;rustc_codegen_jvm&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Bytecode&lt;/td&gt;
&lt;td&gt;JVM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td rowspan="2"&gt;Zig&lt;/td&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;td&gt;Native code&lt;/td&gt;
&lt;td&gt;Desktop OS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://zigwasm.org/" rel="noopener noreferrer"&gt;Zig and WebAssembly&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;WebAssembly&lt;/td&gt;
&lt;td&gt;Wasm runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td rowspan="3"&gt;Swift&lt;/td&gt;
&lt;td rowspan="2"&gt;Native&lt;/td&gt;
&lt;td&gt;Native code&lt;/td&gt;
&lt;td&gt;Desktop OS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ARM machine code&lt;/td&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://swiftwasm.org/" rel="noopener noreferrer"&gt;SwiftWasm&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;WebAssembly&lt;/td&gt;
&lt;td&gt;Wasm runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td rowspan="3"&gt;Python&lt;/td&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;td&gt;Python runtime&lt;/td&gt;
&lt;td&gt;Desktop OS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://www.jython.org/jython-old-sites/archive/22/jythonc.html" rel="noopener noreferrer"&gt;jythonc&lt;/a&gt; (archived)&lt;/td&gt;
&lt;td&gt;Bytecode&lt;/td&gt;
&lt;td&gt;JVM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/wasmerio/py2wasm" rel="noopener noreferrer"&gt;py2wasm&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;WebAssembly&lt;/td&gt;
&lt;td&gt;Wasm runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Again, this is just a snapshot of projects I know at the time of this writing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Discussion
&lt;/h2&gt;

&lt;p&gt;Programming languages were initially focused on a single target and platform. With time, more and more languages broaden their horizon. It comes either as part of the language itself or is an effort from third parties.&lt;/p&gt;

&lt;p&gt;It made sense so far. The bigger your organization's investment in a programming language, the harder it is to pivot to another one. In that light, GraalVM's native is a value proposition for Java-heavy organizations using Kubernetes. The JVM was meant for long-running tasks, while Kubernetes is built on the idea of stopping pods and starting new ones.&lt;/p&gt;

&lt;p&gt;However, with the fast progress of AI, I wonder whether the trend will continue. Some might question the value. Instead of GraalVM'ifying existing Java applications, why not rewrite them directly in Rust, which natively compiles to machine code? This won't happen for core applications at the beginning, but I expect some may experiment with peripheral ones. If the experience nets benefits in terms of resource usage, then it may gnaw at core apps.&lt;/p&gt;

&lt;p&gt;I'm very interested in the next few years to understand whether I'm imagining things, or if it will be a complete upheaval of the landscape.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;To go further:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://goinfotech.blogspot.com/2015/03/scalajs-lets-you-compile-scala-to.html" rel="noopener noreferrer"&gt;Scala.js lets you compile Scala to JavaScript&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.graalvm.org/" rel="noopener noreferrer"&gt;GraalVM&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://teavm.org/" rel="noopener noreferrer"&gt;TeaVM&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/dart-native/dart_native" rel="noopener noreferrer"&gt;DartNative&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/IntegralPilot/rustc_codegen_jvm" rel="noopener noreferrer"&gt;rustc_codegen_jvm&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zigwasm.org/" rel="noopener noreferrer"&gt;Zig and WebAssembly&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://swiftwasm.org/" rel="noopener noreferrer"&gt;SwiftWasm&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;





&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://blog.frankel.ch/programming-languages-targets-platforms/" rel="noopener noreferrer"&gt;A Java Geek&lt;/a&gt; on June 21&lt;sup&gt;st&lt;/sup&gt;, 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>futurism</category>
      <category>python</category>
      <category>coding</category>
    </item>
    <item>
      <title>Seasons time-lapse - the video</title>
      <dc:creator>Nicolas Fränkel</dc:creator>
      <pubDate>Thu, 11 Jun 2026 09:02:00 +0000</pubDate>
      <link>https://dev.to/nfrankel/seasons-time-lapse-the-video-180e</link>
      <guid>https://dev.to/nfrankel/seasons-time-lapse-the-video-180e</guid>
      <description>&lt;p&gt;In the &lt;a href="https://blog.frankel.ch/seasons-time-lapse/1/" rel="noopener noreferrer"&gt;first post&lt;/a&gt; of this series, I focused on the project foundations: what should I do to create a video from photos taken from the same position year after year? I dedicated the &lt;a href="https://blog.frankel.ch/seasons-time-lapse/2/" rel="noopener noreferrer"&gt;second part&lt;/a&gt; to aligning images. It wasn't as easy as I expected. I stumbled upon new concepts, such as ORB and RANSAC.&lt;/p&gt;

&lt;p&gt;In this third and final post, I want to tackle the video creation itself, explain some "artistic" decisions, and leave the door open to future work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Order
&lt;/h2&gt;

&lt;p&gt;The decision with the greatest impact was about ordering pictures. I had several options available. The first thing that came to mind was to order by time of day. There were several problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I mostly run at lunch time. Thus, the passing of time would have been skewed toward a range from noon to 2PM.&lt;/li&gt;
&lt;li&gt;The summer time vs. winter time change would have required a slight change in the pipeline.&lt;/li&gt;
&lt;li&gt;Most importantly, the passing of seasons could have made the video weirdly looking. Frame X could be a sunny blue sky with man-high corn plants, frame X+1 a barren field under the snow, and then frame X+2 in summer again.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I decided instead to first order by day of the year, and only then by time of day. To get the time, I chose the EXIF metadata embedded in the picture itself.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Exif is supported by almost all digital camera manufacturers.&lt;/p&gt;

&lt;p&gt;The metadata tags defined in the Exif standard cover a broad spectrum:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Camera settings: This includes static information such as the camera model and make, and information that varies with each image such as orientation (rotation), aperture, shutter speed, focal length, metering mode, and ISO speed information&lt;/li&gt;
&lt;li&gt;Image metrics: Pixel dimensions, resolution, colorspace, and filesize&lt;/li&gt;
&lt;li&gt;Date and time information, digital cameras will record the current (local) date and time set on the device and save this in the metadata&lt;/li&gt;
&lt;li&gt;Location information&lt;/li&gt;
&lt;li&gt;A thumbnail for previewing the picture on the camera's LCD screen, in file managers, or in photo manipulation software&lt;/li&gt;
&lt;li&gt;Descriptions&lt;/li&gt;
&lt;li&gt;Copyright information&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;— &lt;a href="https://en.wikipedia.org/wiki/Exif" rel="noopener noreferrer"&gt;Wikipedia&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I already extracted the EXIF data to get the location.&lt;/p&gt;

&lt;h2&gt;
  
  
  Images to video
&lt;/h2&gt;

&lt;p&gt;In essence, a video is just a rapid succession of images. With enough frames per second, it gives the illusion of movement.&lt;/p&gt;

&lt;p&gt;To get a feel of what it would look like, I did a straightforward montage. I had three realizations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Creating the video is slow.&lt;/li&gt;
&lt;li&gt;Just putting images one after another was not very aesthetically pleasing.&lt;/li&gt;
&lt;li&gt;The video was quite short.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For point 1, I added code to make the video from a limited sample of pictures. For points 2 and 3, I interpolated frames between the pictures. Interpolation wasn't as easy as I had thought.&lt;/p&gt;

&lt;p&gt;Imagine the pixel with coordinates &lt;code&gt;(1,1)&lt;/code&gt; and color &lt;code&gt;rgb(150,50,100)&lt;/code&gt; on picture 1. The same pixel in picture two has the same coordinates &lt;code&gt;(1,1)&lt;/code&gt;, but color &lt;code&gt;rgb(50,250,120)&lt;/code&gt;. I naively thought that if you inserted a single frame between them, the pixel's color would be the average of both colors' values: &lt;code&gt;rgb((150+50)/2, (50+250)/2, (100+120)/2)&lt;/code&gt;. This approach holds for images taken from the same location with the same angle, which is my use case.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;In theory, there's no difference between theory and practice; in practice, there is.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It turns out that even with images within the same location and angle, there are elements moving between two consecutive images. The most frequent elements are clouds, but because images are not 100% aligned, most elements of interest move slightly. If you apply the above algorithm, clouds will become transparent or appear out of thin air, or probably both at the same time. It doesn't render nicely.&lt;/p&gt;

&lt;p&gt;To improve the rendering, I applied the Gunnar-Farnebäck optical flow.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Optical Flow: Optical flow is known as the pattern of apparent motion of objects, &lt;em&gt;i.e.&lt;/em&gt;, it is the motion of objects between every two consecutive frames of the sequence, which is caused by the movement of the object being captured or the camera capturing it. Consider an object with intensity &lt;em&gt;I (x, y, t)&lt;/em&gt;, after time dt, it moves to by dx and dy, now, the new intensity would be, &lt;em&gt;I (x+dx, y+dy, t+dt)&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;— &lt;a href="https://www.geeksforgeeks.org/python/opencv-the-gunnar-farneback-optical-flow/" rel="noopener noreferrer"&gt;GeeksForGeeks&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You're welcome to read more of the mathematical foundations. Suffice it to say that it fits my use case. Note that frames with too many differences, such as those to or from a fog or snow picture, confuse the algorithm. In this case, I fall back to the simple color blending above. To check whether the fallback activates or not, the computation takes into account:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The average magnitude of the Farneback flow vectors across all pixels. It models large &lt;strong&gt;motion&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The mean absolute difference of grayscale pixel values between the two frames. It represents a large change in overall appearance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Above a certain threshold, fall back.&lt;/p&gt;

&lt;p&gt;I showed the work in progress on the first post, but here it is again:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/bRyJWoSIaho"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Future works
&lt;/h2&gt;

&lt;p&gt;I'm wondering how to even better align the images. The biggest problems are my lack of knowledge in the field and the lack of guaranteed reference points in the landscape I chose. For the former, I'll dig deeper using Claude Code. For the latter, the project is configurable enough to be reused with another picture set. I have fewer of them, but I'm pretty sure I'll be able to use more of them. A bit of disalignment makes the video more dynamic, though.&lt;/p&gt;

&lt;p&gt;I'd also like to use another image set to validate the code's "productization". While I don't plan to make it a product, I'm still interested in whether it's possible and how much freedom it gives people.&lt;/p&gt;

&lt;p&gt;Another axis is Open Sourcing it. You noticed I didn't publish the sources yet. The project is Python, and it's not a language I excel in. Plus, all the code has been generated with Claude Code. Thus, I don't feel very comfortable. If you are interested in helping me publish it, feel free to ping me.&lt;/p&gt;

&lt;p&gt;Finally, I'm thinking about adding a filter option. For this project, I have something impressionist in mind. More specifically, I want to try both a &lt;a href="https://en.wikipedia.org/wiki/J._M._W._Turner" rel="noopener noreferrer"&gt;Turner&lt;/a&gt;'s style and a &lt;a href="https://en.wikipedia.org/wiki/Georges_Seurat" rel="noopener noreferrer"&gt;Seurat&lt;/a&gt; pointillist one. I like their respective work, even though their styles are quite far apart.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F50b1t5gv7cbx54jhi73s.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F50b1t5gv7cbx54jhi73s.jpg" alt="The Slave Ship" width="800" height="601"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feznaaejsnn8qu6j172ve.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feznaaejsnn8qu6j172ve.png" alt="Un dimanche après-midi à l'Île de la Grande Jatte" width="800" height="538"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If I can implement both, then nothing would stop potential users from implementing their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;I had this project in mind for ages. However, only Claude Code allowed me to make it a reality. For once, I have more fun creating something than coding. I actually didn't write a single line of code by myself.&lt;/p&gt;

&lt;p&gt;Have I joined the dark side?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://blog.frankel.ch/seasons-time-lapse/3/" rel="noopener noreferrer"&gt;A Java Geek&lt;/a&gt; on June 7&lt;sup&gt;th&lt;/sup&gt;, 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>video</category>
      <category>timelapse</category>
      <category>art</category>
    </item>
    <item>
      <title>AI gateways: why and how</title>
      <dc:creator>Nicolas Fränkel</dc:creator>
      <pubDate>Thu, 04 Jun 2026 09:02:00 +0000</pubDate>
      <link>https://dev.to/nfrankel/ai-gateways-why-and-how-b5o</link>
      <guid>https://dev.to/nfrankel/ai-gateways-why-and-how-b5o</guid>
      <description>&lt;p&gt;Before working for 2 years on the Apache APISIX API gateway, I was mainly oblivious to API gateways. It's only by working with them that I understood their value. Decoupling the client and the server unlocks a lot of options: &lt;a href="https://dev.to/authentication-api-gateway/"&gt;moving authentication to the API Gateway&lt;/a&gt;, &lt;a href="https://dev.to/secure-api-practices-apisix/1/"&gt;securing&lt;/a&gt; &lt;a href="https://dev.to/secure-api-practices-apisix/2/"&gt;APIs&lt;/a&gt;, &lt;a href="https://dev.to/implement-idempotency-key-apisix/"&gt;deduplicating API requests&lt;/a&gt;, etc.&lt;/p&gt;

&lt;p&gt;In this post, I want to describe how the same pattern applies to AI.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI gateways
&lt;/h2&gt;

&lt;p&gt;AI gateways work in a similar way. They proxy requests and responses between an AI client and its LLM backend(s). Likewise, it unlocks many improvements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AI management: single glass pane to address multiple AI models and providers, simplifying the complexity of integrating and switching between different services.&lt;/li&gt;
&lt;li&gt;Compliance and governance: enforce security, data privacy, and compliance in a single place.&lt;/li&gt;
&lt;li&gt;Cost control: intelligent routing, semantic caching, and budget management.&lt;/li&gt;
&lt;li&gt;Avoiding lock-in: clients depend on an abstraction under control; the gateway manages server API updates and even migrations to a new provider.&lt;/li&gt;
&lt;li&gt;Scalability and reliability: supports automatic failover and load balancing.&lt;/li&gt;
&lt;li&gt;Observability: need I say more?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  My use-case
&lt;/h2&gt;

&lt;p&gt;I love Claude Code; it's amazing, and my experience is more than positive. It has one problem, though: Anthropic is a US-based company, and subject to the Patriot Act. By design, Anthropic must comply with any US government request to access all of my data. As a non-US citizen, I have no right to object. I have no right to be told.&lt;/p&gt;

&lt;p&gt;On the other hand, &lt;a href="https://mistral.ai" rel="noopener noreferrer"&gt;Mistral AI&lt;/a&gt; is a European Union-based company. I have heard pretty good stuff about it, especially &lt;code&gt;devstral&lt;/code&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Today we introduce Devstral, our agentic LLM for software engineering tasks. Devstral is built under a collaboration between Mistral AI and All Hands AI 🙌, and outperforms all open-source models on SWE-Bench Verified by a large margin. We release Devstral under the Apache 2.0 license.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fs148x8g08203uzochuqo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fs148x8g08203uzochuqo.png" alt="Devstral SWE"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;— &lt;a href="https://mistral.ai/news/devstral" rel="noopener noreferrer"&gt;Devstral&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It would be amazing if I could use Claude Code while routing the calls to &lt;code&gt;devstral&lt;/code&gt;. AI gateways are a perfect fit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Available AI gateways
&lt;/h2&gt;

&lt;p&gt;In case you are in need of an AI gateway, please do your due diligence. Because it's exploratory work, I did a pretty shallow search, not deep research. These are the ones that surfaced.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;LiteLLM:&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;LiteLLM&lt;/strong&gt; is an open-source library that gives you a single, unified interface to call 100+ LLMs — OpenAI, Anthropic, Vertex AI, Bedrock, and more — using the OpenAI format.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Call any provider using the same &lt;code&gt;completion()&lt;/code&gt; interface — no re-learning the API for each one&lt;/li&gt;
&lt;li&gt;Consistent output format regardless of which provider or model you use&lt;/li&gt;
&lt;li&gt;Built-in retry / fallback logic across multiple deployments via the Router&lt;/li&gt;
&lt;li&gt;Self-hosted LLM Gateway (Proxy) with virtual keys, cost tracking, and an admin UI&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;— &lt;a href="https://docs.litellm.ai/docs/" rel="noopener noreferrer"&gt;Getting Started&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Bifrost:&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;Bifrost is a high-performance AI gateway that unifies access to 20+ providers OpenAI, Anthropic, AWS Bedrock, Google Vertex, Azure, and more, through a unified API. Deploy in seconds with zero configuration and get automatic failover, load balancing, semantic caching, and enterprise-grade governance. In sustained benchmarks at 5,000 requests per second, Bifrost adds only 11 µs of overhead per request.&lt;/p&gt;

&lt;p&gt;— &lt;a href="https://docs.getbifrost.ai/overview" rel="noopener noreferrer"&gt;Overview&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;OpenRouter:&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;OpenRouter provides a unified API that gives you access to hundreds of AI models through a single endpoint, while automatically handling fallbacks and selecting the most cost-effective options. Get started with just a few lines of code using your preferred SDK or framework.&lt;/p&gt;

&lt;p&gt;— &lt;a href="https://openrouter.ai/docs/quickstart" rel="noopener noreferrer"&gt;Quickstart&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I chose Bifrost for several reasons. It's self-hosted, it's close to the metal (it's written in Go), and the quickstart is dead simple via &lt;code&gt;npx&lt;/code&gt;. It is also available via a Docker image. It has a UI, which is great for exploring features. Finally, it has baked-in observability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bifrost 5-minute quick start
&lt;/h2&gt;

&lt;p&gt;Starting Bifrost is a single command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx &lt;span class="nt"&gt;-y&lt;/span&gt; @maximhq/bifrost
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The menu's first item shows the telemetry dashboard. It's quite useful.&lt;/p&gt;

&lt;p&gt;The first step is to create a model provider for Mistral. Go to &lt;strong&gt;Models &amp;gt; Model Providers&lt;/strong&gt;. Then click on &lt;strong&gt;+ Add New Provider&lt;/strong&gt;. Finally, choose &lt;strong&gt;+ Add new key&lt;/strong&gt; and fill the fields accordingly. You should have previously generated a &lt;a href="https://admin.mistral.ai/organization/api-keys" rel="noopener noreferrer"&gt;Mistral API key&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The second step is to create a routing rule so that Claude Code requests targeting any of Anthropic's models target Mistral instead. Go to &lt;strong&gt;Models &amp;gt; Routing Rules&lt;/strong&gt;. Click on &lt;strong&gt;+ New rule&lt;/strong&gt;, then inside the opening menu, on &lt;strong&gt;+ Add Target&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Provider: Mistral AI&lt;/li&gt;
&lt;li&gt;Model: &lt;code&gt;devstral-2512&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Leave all other fields blank.&lt;/p&gt;

&lt;p&gt;Before launching Claude Claude, export the following environment variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ANTHROPIC_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:8080/anthropic     &lt;span class="c"&gt;# 1&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ANTHROPIC_AUTH_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sk-dummy                          &lt;span class="c"&gt;# 2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Use the local Bifrost instead of the default Anthropic URL&lt;/li&gt;
&lt;li&gt;Unused, but necessary&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It should just be a matter of starting Claude Code and having fun. Unfortunately, it fails.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ hello
  ⎿  API Error: 422 {"type":"error","error":{"type":"api_error","message":"provider API error (status 422)"}}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Debugging the setup
&lt;/h2&gt;

&lt;p&gt;Failures are always great opportunities to understand a solution better. On the provider, click on &lt;strong&gt;Edit Provider Config&lt;/strong&gt;. Set the switch of the &lt;strong&gt;Debugging&lt;/strong&gt; tab.&lt;/p&gt;

&lt;p&gt;Then, in &lt;strong&gt;Observability &amp;gt; LLM Logs&lt;/strong&gt;, select the failing request. Find the section named &lt;em&gt;Raw Response from Mistral&lt;/em&gt;. It looks something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"detail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"enum"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"loc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="s2"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="s2"&gt;"reasoning_effort"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"msg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Input should be 'none' or 'high'"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"medium"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"ctx"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"expected"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"'none' or 'high'"&lt;/span&gt;&lt;span class="w"&gt;                &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"invalid_request_error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"param"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"raw_status_code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;422&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;There's a mismatch between the request sent by Claude Code and what Mistral expects.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;At this point, I had pinpointed the issue. I opened a &lt;a href="https://github.com/maximhq/bifrost/issues/2196" rel="noopener noreferrer"&gt;bug report&lt;/a&gt;. The feedback came back in less than an hour, and the fix attempt was in a couple of weeks. It still doesn't work, but I found a workaround.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_CODE_DISABLE_THINKING&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Extended thinking is the reasoning Claude emits before responding. On models that support adaptive reasoning, the effort level is the primary control for how much thinking happens; the settings below turn thinking on or off and control how it displays.&lt;/p&gt;

&lt;p&gt;— &lt;a href="https://code.claude.com/docs/en/model-config#extended-thinking" rel="noopener noreferrer"&gt;Extended thinking&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;At this point, it works, but at the cost of a lack of higher reasoning.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1s8nn2fg6fz7214nrcfs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1s8nn2fg6fz7214nrcfs.png" alt="Request log in Bifrost"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Budget management
&lt;/h2&gt;

&lt;p&gt;The above is good for a personal setup, but in enterprise settings, you'll rarely see this approach, if ever. What you regularly see, however, is a monthly spending limit. This situation has two issues:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Users must be able to track their usage. Without precise tracking, they run the risk of consuming too many tokens too early.&lt;/li&gt;
&lt;li&gt;If you overspend, you are stuck until the counter resets, which generally happens at the start of the next month. Ask me how I know.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AI gateways in general, and Bifrost in particular, allow lots of options to handle both. I'll leave the metering aside for this post to focus on the usage itself. The AI gateway could do a couple of things to help manage the budget.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Set a daily cap. It's better to be capped for half an hour at the end of the day than for more than a day at the end of the month.&lt;/li&gt;
&lt;li&gt;Redirect request using specific models to other models. You can use it to prevent users from using expensive models. At the moment of this writing, Claude Opus tokens cost at least 5 times more than Claude Sonnet's.&lt;/li&gt;
&lt;li&gt;Degrade model when over-budget. Once users have gone over a specific budget, redirect requests to a less expensive model.&lt;/li&gt;
&lt;li&gt;Redirect to self-hosted model when over-budget.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are just a couple of the use cases that you could implement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Simple fallback
&lt;/h2&gt;

&lt;p&gt;In this section, I want to demo the last item. In an enterprise setting, you could host the fallback on a cloud instance or even self-host. In the context of this post, I'll limit myself to falling back to a local &lt;code&gt;llama-server&lt;/code&gt; if above the set limit.&lt;/p&gt;

&lt;p&gt;The first step is to define a spending limit. Go to the Mistral provider defined earlier. Set the limit in the Governance tab. Here's where I set the cap to 100,000 tokens daily for demo purposes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw1q2yuav9tnu9btuczhc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw1q2yuav9tnu9btuczhc.png" alt="Set a token limit for Mistral AI"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The second step is to add a Llama Server provider. It's type custom.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;API Structure:&lt;/strong&gt; The base is OpenAI, because &lt;code&gt;llama-server&lt;/code&gt; is compatible. Remember to limit the Allowed Request Types to what the model can actually do. For my model, it's Chat Completion and Chat Completion Stream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network:&lt;/strong&gt; Set the Base URL to &lt;code&gt;http://localhost:&amp;lt;port&amp;gt;&lt;/code&gt;. Because Bifrost binds on port &lt;code&gt;8080&lt;/code&gt;, I run &lt;code&gt;llama-server&lt;/code&gt; on &lt;code&gt;8081&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The third and final step is to add a fallback to the existing routing rule.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpgbnfxtibunnn6enc3eh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpgbnfxtibunnn6enc3eh.png" alt="Set a routing fallback"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With the low number of tokens set, you'll hit the ceiling after one or two requests.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F322j92p6zse6jjlfynr9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F322j92p6zse6jjlfynr9.png" alt="Error message when the token cap has been reached"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On the next request, Bifrost will:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Evaluate the condition&lt;/li&gt;
&lt;li&gt;Prevent the request from reaching Mistral&lt;/li&gt;
&lt;li&gt;And send it to the fallback Llama server instead.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Bifrost logs the two requests, the first one to Mistral AI and the second to Llama. Here's a sample:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8y8rox69vhmwen9a3z7z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8y8rox69vhmwen9a3z7z.png" alt="Request logs"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;AI gateways are a fantastic architectural improvement. Bifrost looks great on paper, and the dashboard seems quite exhaustive.&lt;/p&gt;

&lt;p&gt;Unfortunately, a Bifrost bug currently prevents the implementation of my extended thinking. I hope maintainers fix it fast and I can work further on AI gateways "for real".&lt;/p&gt;

&lt;p&gt;However, when it works, I'll be able to leverage Claude Code's amazing client with the LLM of my choice, including Devstral.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;To go further:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://mistral.ai" rel="noopener noreferrer"&gt;Mistral AI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://mistral.ai/news/devstral" rel="noopener noreferrer"&gt;devstral&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.getmaxim.ai/bifrost" rel="noopener noreferrer"&gt;Bifrost&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/maximhq/bifrost" rel="noopener noreferrer"&gt;Bifrost GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/settings" rel="noopener noreferrer"&gt;Claude Code settings&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://blog.frankel.ch/ai-gateways/" rel="noopener noreferrer"&gt;A Java Geek&lt;/a&gt; on May 31&lt;sup&gt;st&lt;/sup&gt;, 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>codingassistants</category>
      <category>aigateway</category>
      <category>claude</category>
    </item>
    <item>
      <title>Seasons time-lapse - alignment</title>
      <dc:creator>Nicolas Fränkel</dc:creator>
      <pubDate>Thu, 28 May 2026 09:02:00 +0000</pubDate>
      <link>https://dev.to/nfrankel/seasons-time-lapse-alignment-5chc</link>
      <guid>https://dev.to/nfrankel/seasons-time-lapse-alignment-5chc</guid>
      <description>&lt;p&gt;In the &lt;a href="https://blog.frankel.ch/seasons-time-lapse/1/" rel="noopener noreferrer"&gt;previous post&lt;/a&gt;, I described the Seasons project: a time-lapse of hundreds of pictures taken from nearly the same viewpoint over the years. The hardest challenge wasn't taking the pictures or assembling them, but aligning them.&lt;/p&gt;

&lt;p&gt;You might have noticed the &lt;em&gt;nearly&lt;/em&gt; part about viewpoint in the above paragraph. Indeed, it's an approximation. I'm a human being, not a tripod. The position changes ever so slightly, and so does the exact angle. My phone has changed over the years, from a Samsung S10 to an S21, and now I'm using an iPhone 14 Pro. Each of them has a different camera with a different focal length. The resulting photos look similar but are definitely not identical: slightly different scales and angles. Without any correction, the landscape appears to be moving a lot.&lt;/p&gt;

&lt;p&gt;Alignment is the process of warping each photo so it looks like it was taken from exactly the same angle as a reference image.&lt;/p&gt;

&lt;h2&gt;
  
  
  OpenCV for feature matching
&lt;/h2&gt;

&lt;p&gt;For image-related features, OpenCV is the go-to library.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;OpenCV is the world's biggest computer vision library.&lt;/p&gt;

&lt;p&gt;OpenCV is open source, contains over 2500 algorithms, and is operated by the non-profit Open Source Vision Foundation.&lt;/p&gt;

&lt;p&gt;—- &lt;a href="https://opencv.org/" rel="noopener noreferrer"&gt;OpenCV&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The most straightforward approach is to find key points across images, match them, then compute a geometric transform from the matches. It's &lt;em&gt;feature matching&lt;/em&gt;. OpenCV provides an algorithm for that called &lt;strong&gt;ORB&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;ORB is basically a fusion of FAST keypoint detector and BRIEF descriptor with many modifications to enhance the performance. First it use FAST to find keypoints, then apply Harris corner measure to find top N points among them. It also use pyramid to produce multiscale-features.&lt;/p&gt;

&lt;p&gt;—- &lt;a href="https://docs.opencv.org/4.x/d1/d89/tutorial_py_orb.html" rel="noopener noreferrer"&gt;ORB (Oriented FAST and Rotated BRIEF)&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I mentioned two steps above: first, matching key points, then transforming the rest of them. Once key points are matched, you need an algorithm for the transformation. Meet RANSAC:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The RANSAC algorithm is a learning technique to estimate parameters of a model by random sampling of observed data. Given a dataset whose data elements contain both inliers and outliers, RANSAC uses the voting scheme to find the optimal fitting result. Data elements in the dataset are used to vote for one or multiple models. The implementation of this voting scheme is based on two assumptions: that the noisy features will not vote consistently for any single model (few outliers) and there are enough features to agree on a good model (few missing data).&lt;/p&gt;

&lt;p&gt;—- &lt;a href="https://en.wikipedia.org/wiki/Random_sample_consensus" rel="noopener noreferrer"&gt;RANSAC&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I first used a 3x3 matrix, also called &lt;em&gt;homography&lt;/em&gt; for the transformation. It resulted in very distorted images with perspective distortion. Every small error in keypoint matching was amplified. I fixed it by using a smaller matrix, 2x3, which only handled translation and uniform scale.&lt;/p&gt;

&lt;p&gt;That still left a nondeterministic problem. RANSAC is randomised by design. Run it twice on the same data, and you may get slightly different rotations. For a time-lapse, that means the horizon can drift from frame to frame. The fix was to discard rotation entirely and force it to zero after RANSAC, then recompute the translation analytically from the inlier matches.&lt;/p&gt;

&lt;p&gt;The scale tolerance also required careful tuning. The Samsung S10 has a 4.30mm focal length, while the iPhone 14 Pro has a 6.86mm one. A photo from one device is 1.6× the scale of a photo from the other. Any scale gate tighter than that would wrongly reject valid cross-device frames.&lt;/p&gt;

&lt;p&gt;There was one more spatial problem. ORB keypoints cluster on high-texture regions — in my photos, trees and rooftops. A transform fitted only from one corner of the image is less stable than one fitted from points spread across the whole frame. The fix was to divide the image into a 4×4 grid and cap the number of keypoints per cell, forcing a more even spatial distribution.&lt;/p&gt;

&lt;p&gt;Finally, the output resolution. Each aligned frame produces a valid crop region — the area of the reference frame it fully covers. If you take the intersection of all crops, one frame with an extreme offset shrinks the output for everyone. Instead, the crop boundaries are computed from percentiles across all frames: the worst 15% of frames on each edge are ignored, and any frame that falls outside the resulting crop is dropped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Neural matching with SuperPoint and LightGlue
&lt;/h2&gt;

&lt;p&gt;ORB works well on scenes with clear texture. In winter, photos with snow-covered fields, foggy mornings, and overcast skies degrade quickly. There is simply not enough distinctive texture for classical descriptors to anchor on. I kept it as a fallback when the following approach doesn't fall within the expected range.&lt;/p&gt;

&lt;p&gt;The first step is a pair of Open Source neural models:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/rpautrat/SuperPoint" rel="noopener noreferrer"&gt;SuperPoint&lt;/a&gt; is a self-supervised keypoint detector trained to find stable, repeatable points across varying lighting and viewpoints.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/cvg/LightGlue" rel="noopener noreferrer"&gt;LightGlue&lt;/a&gt; is a transformer-based matcher that pairs SuperPoint keypoints across two images and produces a confidence score for each match.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both run locally on Apple Silicon via MPS.&lt;/p&gt;

&lt;p&gt;SuperPoint needs a reference image. To avoid extracting its features hundreds of times, they are computed once at startup and reused for every frame.&lt;/p&gt;

&lt;p&gt;The first version of the quality score was wrong. I measured it as the fraction of LightGlue matches that survived RANSAC. That made almost every frame poorly aligned.&lt;/p&gt;

&lt;p&gt;The root cause was parallax. LightGlue finds geometrically correct matches, the same real-world point in both images, but many are rejected by RANSAC because the scene has depth. Objects at different distances don't move the same way when the camera shifts slightly. The matches aren't wrong; they just don't fit a flat-world model.&lt;/p&gt;

&lt;p&gt;The fix was to score alignment using the mean LightGlue confidence of the RANSAC inliers only. With trial and error, I found that a good alignment score sits between 0.82 and 0.96. Frame acceptance went from 19.7% to 53.2%.&lt;/p&gt;

&lt;p&gt;Here's the final representation of the alignment pipeline's nominal path:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3wewqzn92qungqlhpuo3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3wewqzn92qungqlhpuo3.png" alt="Final representation of the alignment pipeline" width="799" height="287"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the next part, I'll describe how I smoothed the motion between frames using optical flow and the final result.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://blog.frankel.ch/seasons-time-lapse/2/" rel="noopener noreferrer"&gt;A Java Geek&lt;/a&gt; on May 24&lt;sup&gt;th&lt;/sup&gt;, 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>art</category>
      <category>timelapse</category>
    </item>
    <item>
      <title>Seasons time-lapse - the foundations</title>
      <dc:creator>Nicolas Fränkel</dc:creator>
      <pubDate>Thu, 21 May 2026 09:02:00 +0000</pubDate>
      <link>https://dev.to/nfrankel/seasons-time-lapse-the-foundations-4d4b</link>
      <guid>https://dev.to/nfrankel/seasons-time-lapse-the-foundations-4d4b</guid>
      <description>&lt;p&gt;I live close to nature. I regularly go for a run in the countryside. Over several years, during my runs, I've taken pictures from the same position, always roughly the same angle. I had a vague idea in the back of my mind, as an "artistic" project. One day, I'd turn those photos into a time-lapse video, one that would show the passage of seasons across a single place.&lt;/p&gt;

&lt;p&gt;Spoiler, here's the work in progress:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/bRyJWoSIaho"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;However, I knew that this project would take ages. I have no experience in image manipulation and video making. My knowledge of codecs is that the movie I have doesn't play on my Internet box.&lt;/p&gt;

&lt;p&gt;The amazing progress in coding assistants made this project possible. I hate the term "vibe coding", and I hope I provided accurate technical direction, but I admit I didn't do anything on my own. I just fed my instructions to the assistant, and it did the job.&lt;/p&gt;

&lt;p&gt;In this multi-part series, I aim to reflect on my actions. There's no reason why we can't have a useful retrospective in LLM-assisted projects.&lt;/p&gt;

&lt;p&gt;In any regular project, choosing your stack is the most important part. You can refer to &lt;a href="https://blog.frankel.ch/choosing-dependency/" rel="noopener noreferrer"&gt;Choosing a dependency&lt;/a&gt; for them. Vibe-coding adepts will gladly tell you that code is not an asset anymore, but that you can (and should) discard it and start from scratch at every iteration.&lt;/p&gt;

&lt;p&gt;After describing my idea and its foundation, I asked the assistant whether to choose between the JVM, Python, or any other stack. It chose Python. At several steps in the process, I asked it to reassess its choice again. Still Python.&lt;/p&gt;

&lt;p&gt;My argument for performance got rebuked:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The performance argument doesn't apply. The slow parts — OpenCV operations, neural inference — run as C++/CUDA/Metal underneath regardless of whether you call them from Python or Java. Python's interpreter overhead is irrelevant here.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To keep things in check and help the assistant, I enforce types and tests.&lt;/p&gt;

&lt;p&gt;The project is fundamentally a processing pipeline. Steps are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Inventory: glob all configured directories for photos and extract EXIF metadata, date, focal length, GPS coordinates.&lt;/li&gt;
&lt;li&gt;Filter: keep only photos taken within 100 metres of a configured reference GPS point. Many photos lacked GPS data entirely, those are dropped.&lt;/li&gt;
&lt;li&gt;Align: warp each photo so it looks like it was taken from exactly the same angle as a reference image. This is the hard part, and it gets its own post.&lt;/li&gt;
&lt;li&gt;Order: sort the aligned frames according to a simple algorithm. I chose by day-of-year and time-of-day to assemble a composite year from photos taken across multiple real years.&lt;/li&gt;
&lt;li&gt;Render: encode the ordered frames into a video.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The pipeline is driven by a config file at the project root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[input]&lt;/span&gt;
&lt;span class="py"&gt;dirs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"/path/to/photos/1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"/path/to/photos/2"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;        &lt;span class="c"&gt;# 1&lt;/span&gt;
&lt;span class="py"&gt;extensions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;".heic"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;".jpg"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;".jpeg"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;                  &lt;span class="c"&gt;# 2&lt;/span&gt;

&lt;span class="nn"&gt;[reference]&lt;/span&gt;
&lt;span class="py"&gt;dir&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"reference"&lt;/span&gt;
&lt;span class="py"&gt;latitude&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;46.135536&lt;/span&gt;
&lt;span class="py"&gt;longitude&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;6.111831&lt;/span&gt;

&lt;span class="nn"&gt;[filter]&lt;/span&gt;
&lt;span class="py"&gt;gps_radius_m&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;100.0&lt;/span&gt;

&lt;span class="nn"&gt;[output]&lt;/span&gt;
&lt;span class="py"&gt;fps&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;
&lt;span class="py"&gt;blend&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;The autosave application changed its default location&lt;/li&gt;
&lt;li&gt;I changed my phone as well&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;GPS coordinates are mandatory. The pipeline fails early if they're missing. Without them, the filter step has nothing to anchor to.&lt;/p&gt;

&lt;p&gt;To iterate more quickly, I weaved in a &lt;code&gt;--sample&lt;/code&gt; flag to run the full pipeline on a random subset of photos. A full run processes hundreds of images and takes minutes; a 50-image sample takes seconds. Iterating on the alignment code was initially too time-consuming without sampling.&lt;/p&gt;

&lt;p&gt;The inventory, filter, order, and render steps are largely straightforward. Alignment is not. In the next part, I'll describe how I went from a naive approach that produced wobbly, misaligned frames to a neural matcher that gets most frames right.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://blog.frankel.ch/seasons-time-lapse/1/" rel="noopener noreferrer"&gt;A Java Geek&lt;/a&gt; on May 17&lt;sup&gt;th&lt;/sup&gt;, 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>art</category>
      <category>timelapse</category>
    </item>
    <item>
      <title>Tokensparsamkeit for coding assistants</title>
      <dc:creator>Nicolas Fränkel</dc:creator>
      <pubDate>Thu, 14 May 2026 09:02:00 +0000</pubDate>
      <link>https://dev.to/nfrankel/tokensparsamkeit-for-coding-assistants-al2</link>
      <guid>https://dev.to/nfrankel/tokensparsamkeit-for-coding-assistants-al2</guid>
      <description>&lt;p&gt;You make decisions with data. Most businesses assumed that the most data, the better the decision. Then, several factors put a halt to the hoarding of always more data. GDPR and its localized counterparts, and the cost of storage. However, before it happened, the Datensparsamkeit approach already existed.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Datensparsamkeit is a German word that's difficult to translate properly into English. It's an attitude to how we capture and store data, saying that we should only handle data that we really need.&lt;/p&gt;

&lt;p&gt;— &lt;a href="https://martinfowler.com/bliki/Datensparsamkeit.html" rel="noopener noreferrer"&gt;Datensparsamkeit&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I don't agree with Martin Fowler's claim that it's difficult to translate. The translation of Sparsamkeit is &lt;em&gt;frugality&lt;/em&gt;. In the context of coding assistants, token frugality is a good thing.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Today, critical resources aren't CPU, RAM, or storage, but tokens. Tokens are a finite and expensive resource. My opinion is that soon, developers will be measured on their token usage: the better one will be the one using the fewest tokens to achieve similar results.&lt;/p&gt;

&lt;p&gt;— &lt;a href="https://blog.frankel.ch/writing-agent-skill/" rel="noopener noreferrer"&gt;Writing an agent skill&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Imagine two engineers finishing the same job with the same quality in the same timeframe. If the organization needs to let go of one, it will be the one that costs more. In the era of AI, it means the one who consumes more tokens.&lt;/p&gt;

&lt;p&gt;In this post, I want to show a couple of methods to keep the usage of tokens small.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compression
&lt;/h2&gt;

&lt;p&gt;One of the first steps toward Tokensparsamkeit is to compress tokens sent to the underlying LLM &lt;strong&gt;while keeping the same data&lt;/strong&gt;. But what are tokens? It's a gross oversimplification, but you for the sake of explanation, let's consider a word is a token. Read this &lt;a href="https://letsdatascience.com/blog/tokenization-deep-dive-why-it-matters-more-than-you-think" rel="noopener noreferrer"&gt;deep dive&lt;/a&gt; if you want more details.&lt;/p&gt;

&lt;p&gt;If we consider tokens are words, we could remove articles and similar words from the payload to decrease the tokens number. "Find the distance between the Earth and the moon" becomes "Find distance between Earth and moon". For all intents and purpose, the data received is the same, with less words.&lt;/p&gt;

&lt;p&gt;The trick is to set a proxy between the client and the LLM backend. I'm using &lt;code&gt;rtk&lt;/code&gt; myself:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;CLI proxy that reduces LLM token consumption by 60-90% on common dev commands. Single Rust binary, zero dependencies&lt;/p&gt;

&lt;p&gt;— &lt;a href="https://github.com/rtk-ai/rtk" rel="noopener noreferrer"&gt;rtk project on GitHub&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The tool works across file commands, &lt;code&gt;git&lt;/code&gt;, &lt;code&gt;gh&lt;/code&gt;, test runners, build/lint commands, &lt;code&gt;aws&lt;/code&gt;, &lt;code&gt;docker&lt;/code&gt;, &lt;code&gt;kubectl&lt;/code&gt;, etc. Note that it's not a magical recipe, as &lt;code&gt;rtk&lt;/code&gt; itself mentions:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This only applies to Bash tool calls. Claude Code built-in tools such as Read, Grep, and Glob bypass the hook, so use shell commands or explicit rtk commands when you want RTK filtering there.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Context optimization
&lt;/h2&gt;

&lt;p&gt;The second step toward Tokensparsamkeit is to avoid stuffing the context with irrelevant data.&lt;/p&gt;

&lt;p&gt;Most people who start using coding assistants assume the context only consists of the system prompt and user prompts. There actually is a lot more. Anthropic's &lt;a href="https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents" rel="noopener noreferrer"&gt;Effective context engineering for AI agents&lt;/a&gt; article mentions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;System prompt&lt;/li&gt;
&lt;li&gt;User prompt&lt;/li&gt;
&lt;li&gt;Message history&lt;/li&gt;
&lt;li&gt;Tool definitions&lt;/li&gt;
&lt;li&gt;Tool results&lt;/li&gt;
&lt;li&gt;MCP servers&lt;/li&gt;
&lt;li&gt;RAG&lt;/li&gt;
&lt;li&gt;Agent memory if applicable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Claude Code introduced the option to compact (or clear?) the context before each interaction. It explicitly asked with each interaction whether to do it. I liked it, but they removed it a week or so later. Perhaps too many people didn't understand what it entailed? In any case, make good use of the &lt;code&gt;/compact&lt;/code&gt; command that most assistants provide: it will reduce the conversation history to reduce its token usage, while trying to keep the relevant bits.&lt;/p&gt;

&lt;p&gt;Also notice that tools and MCP servers use tokens; the more you configure, the more tokens used. Some MCP servers are so easy to set up, it's tempting to stuff your assistant with them. Don't. Or enable them either on a case-by-case basis or at the project level. Why enable the Vaadin MCP on a Rust project?&lt;/p&gt;

&lt;p&gt;The same goes for tools, although I don't think many do use them a lot in comparison to MCP servers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local models
&lt;/h2&gt;

&lt;p&gt;Tokens usage is only important for cloud-based billing. We don't care about it if we use a local model. There are several ways to do it, including AI gateways. In the scope of this article, I'll keep it simple.&lt;/p&gt;

&lt;p&gt;I want to keep Claude Code as the client, because it's really good. At the same time, I want to use my own hardware with a local model: the cost is upfront, but then I have zero recurring cost but the power.&lt;/p&gt;

&lt;p&gt;If you want to just do it, &lt;a href="https://unsloth.ai/docs/basics/claude-code" rel="noopener noreferrer"&gt;How to Run Local LLMs with Claude Code&lt;/a&gt; is where I found the solution. Continue reading the section if you want to read about the issues I faced.&lt;/p&gt;

&lt;p&gt;I tried initially to run Qwen3 32B via Ollama in Docker. Docker containers cannot access Apple's Metal GPU framework, so the model ran entirely on CPU. It loaded successfully but crashed during inference with a 500 error; CPU-only inference on a 32B model is simply too slow to be usable.&lt;/p&gt;

&lt;p&gt;I have been using Ollama a bit as the default, because others did. Then I stumbled upon &lt;a href="https://sleepingrobots.com/dreams/stop-using-ollama" rel="noopener noreferrer"&gt;Friends Don't Let Friends Use Ollama&lt;/a&gt;. I switched from Ollama to llama-cpp, which enabled low-level configuration.&lt;/p&gt;

&lt;p&gt;The biggest hurdle was the context window size. Claude Code sends &lt;strong&gt;lots&lt;/strong&gt; of tokens to the backend. On the &lt;a href="https://github.com/nfrankel/opentelemetry-tracing/" rel="noopener noreferrer"&gt;OpenTelemetry tracing demo&lt;/a&gt;, it's around 35k on each request.&lt;/p&gt;

&lt;p&gt;I started with Qwen3 models. The default token size wasn't big enough. When a model received more tokens than its maximum, llama server immediately rejects the request. I tried to increase the limit with the &lt;code&gt;--ctx-size&lt;/code&gt; option, to no avail. Qwen3 modelsare trained with 32,768 tokens. It's a hard limit baked into the GGUF file metadata. Llama server abides by it.&lt;/p&gt;

&lt;p&gt;Llama server is meant to serve multiple requests simultaneously. It turns out that the count of available tokens is shared equally across all possible requests. If the number of max tokens is &lt;code&gt;T&lt;/code&gt; and the server can handle &lt;code&gt;x&lt;/code&gt; requests in parallel, each request only has &lt;code&gt;T/x&lt;/code&gt; tokens available. For this reason, I set the parallelism with &lt;code&gt;--parallel 1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Despite all of the above, it still didn't work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mixture of Experts vs. dense models
&lt;/h2&gt;

&lt;p&gt;I was using a &lt;em&gt;dense model&lt;/em&gt;, which is what we use regularly. Dense models load all in memory at once. The alternative is to use a Mixture of Experts model.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;In the context of transformer models, a MoE consists of two main elements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sparse MoE layers&lt;/strong&gt; are used instead of dense feed-forward network (FFN) layers. MoE layers have a certain number of "experts" (e.g. 8), where each expert is a neural network. In practice, the experts are FFNs, but they can also be more complex networks or even a MoE itself, leading to hierarchical MoEs!&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;gate network or router&lt;/strong&gt;, that determines which tokens are sent to which expert. For example, in the image below, the token "More" is sent to the second expert, and the token "Parameters" is sent to the first network. As we'll explore later, we can send a token to more than one expert. How to route a token to an expert is one of the big decisions when working with MoEs - the router is composed of learned parameters and is pretrained at the same time as the rest of the network.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;— &lt;a href="https://huggingface.co/blog/moe" rel="noopener noreferrer"&gt;What is a Mixture of Experts&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In layman's terms, a MoE segments its weights/parameters into separate specialized submodels called experts. A routing layer activates only the necessary experts depending on the request. Compared to regular dense models, instead of computing across the entire model of size &lt;code&gt;T&lt;/code&gt;, only a small subset of experts is activated per request. The combined size of activated experts &lt;code&gt;t&lt;/code&gt; is much smaller than &lt;code&gt;T&lt;/code&gt;, even though the sum of all experts together is larger than &lt;code&gt;T&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The Qwen3.5-35B-A3B model is a MoE that works perfectly on my machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it all together
&lt;/h2&gt;

&lt;p&gt;We still miss a couple of elements to reach the goal.&lt;/p&gt;

&lt;p&gt;To better interact with Claude Code, the model should return structured content. That's what the &lt;code&gt;--jinja&lt;/code&gt; flag is for. For better performance, you should also use &lt;a href="https://bentoml.com/llm/kernel-optimization/flashattention" rel="noopener noreferrer"&gt;Flash Attention&lt;/a&gt;. It's an optimized algorithm for computing the attention mechanism in Transformer models. It's faster, more memory-efficient, and more scalable than standard attention. Activate it via &lt;code&gt;--flash-attn on&lt;/code&gt;. The last configuration parameter is to offload as many layers as possible to the GPU with &lt;code&gt;--n-gpu-layers 99&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The final server command line is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;llama-server &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--model&lt;/span&gt; ~/models/Qwen3.5-35B-A3B-Q4_K_M.gguf &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--n-gpu-layers&lt;/span&gt; 99 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--ctx-size&lt;/span&gt; 65536 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--parallel&lt;/span&gt; 1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--flash-attn&lt;/span&gt; on &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--jinja&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--port&lt;/span&gt; 8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the Claude Code side, we need to set several environment variables:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Environment variable&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ANTHROPIC_BASE_URL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;URL to the &lt;code&gt;llama-server&lt;/code&gt; instance&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://127.0.0.1:8080&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Anything&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dummy&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ANTHROPIC_AUTH_TOKEN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Anything&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dummy&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Self-explicit&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ANTHROPIC_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://127.0.0.1:8080
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dummy
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ANTHROPIC_AUTH_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dummy
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
claude
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At this point, you can use Claude Code, which will query your local model. Here's a sample server output for a query, for information.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;srv  params_from_: Chat format: peg-native
&lt;/span&gt;&lt;span class="gp"&gt;slot get_availabl: id  0 | task -1 | selected slot by LCP similarity, sim_best = 0.788 (&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;0.100 thold&lt;span class="o"&gt;)&lt;/span&gt;, f_keep &lt;span class="o"&gt;=&lt;/span&gt; 0.789
&lt;span class="gp"&gt;slot launch_slot_: id  0 | task -1 | sampler chain: logits -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;?penalties -&amp;gt; ?dry -&amp;gt; ?top-n-sigma -&amp;gt; top-k -&amp;gt; ?typical -&amp;gt; top-p -&amp;gt; min-p -&amp;gt; ?xtc -&amp;gt; temp-ext -&amp;gt; dist 
&lt;span class="go"&gt;slot launch_slot_: id  0 | task 1464 | processing task, is_child = 0
slot update_slots: id  0 | task 1464 | new prompt, n_ctx_slot = 65536, n_keep = 0, task.n_tokens = 56401
slot update_slots: id  0 | task 1464 | n_past = 44456, slot.prompt.tokens.size() = 56378, seq_id = 0, pos_min = 56377, n_swa = 0
slot update_slots: id  0 | task 1464 | Checking checkpoint with [56141, 56141] against 44456...
slot update_slots: id  0 | task 1464 | Checking checkpoint with [55629, 55629] against 44456...
slot update_slots: id  0 | task 1464 | Checking checkpoint with [49151, 49151] against 44456...
slot update_slots: id  0 | task 1464 | Checking checkpoint with [40959, 40959] against 44456...
slot update_slots: id  0 | task 1464 | restored context checkpoint (pos_min = 40959, pos_max = 40959, n_tokens = 40960, n_past = 40960, size = 62.813 MiB)
slot update_slots: id  0 | task 1464 | erased invalidated context checkpoint (pos_min = 49151, pos_max = 49151, n_tokens = 49152, n_swa = 0, pos_next = 40960, size = 62.813 MiB)
slot update_slots: id  0 | task 1464 | erased invalidated context checkpoint (pos_min = 55629, pos_max = 55629, n_tokens = 55630, n_swa = 0, pos_next = 40960, size = 62.813 MiB)
slot update_slots: id  0 | task 1464 | erased invalidated context checkpoint (pos_min = 56141, pos_max = 56141, n_tokens = 56142, n_swa = 0, pos_next = 40960, size = 62.813 MiB)
slot update_slots: id  0 | task 1464 | n_tokens = 40960, memory_seq_rm [40960, end)
slot update_slots: id  0 | task 1464 | prompt processing progress, n_tokens = 43008, batch.n_tokens = 2048, progress = 0.762540
slot update_slots: id  0 | task 1464 | n_tokens = 43008, memory_seq_rm [43008, end)
slot update_slots: id  0 | task 1464 | prompt processing progress, n_tokens = 45056, batch.n_tokens = 2048, progress = 0.798851
slot update_slots: id  0 | task 1464 | n_tokens = 45056, memory_seq_rm [45056, end)
slot update_slots: id  0 | task 1464 | prompt processing progress, n_tokens = 47104, batch.n_tokens = 2048, progress = 0.835163
slot update_slots: id  0 | task 1464 | n_tokens = 47104, memory_seq_rm [47104, end)
slot update_slots: id  0 | task 1464 | prompt processing progress, n_tokens = 49152, batch.n_tokens = 2048, progress = 0.871474
slot update_slots: id  0 | task 1464 | n_tokens = 49152, memory_seq_rm [49152, end)
slot update_slots: id  0 | task 1464 | 8192 tokens since last checkpoint at 40960, creating new checkpoint during processing at position 51200
slot update_slots: id  0 | task 1464 | prompt processing progress, n_tokens = 51200, batch.n_tokens = 2048, progress = 0.907785
slot update_slots: id  0 | task 1464 | created context checkpoint 6 of 32 (pos_min = 49151, pos_max = 49151, n_tokens = 49152, size = 62.813 MiB)
slot update_slots: id  0 | task 1464 | n_tokens = 51200, memory_seq_rm [51200, end)
slot update_slots: id  0 | task 1464 | prompt processing progress, n_tokens = 53248, batch.n_tokens = 2048, progress = 0.944097
slot update_slots: id  0 | task 1464 | n_tokens = 53248, memory_seq_rm [53248, end)
slot update_slots: id  0 | task 1464 | prompt processing progress, n_tokens = 55296, batch.n_tokens = 2048, progress = 0.980408
slot update_slots: id  0 | task 1464 | n_tokens = 55296, memory_seq_rm [55296, end)
slot update_slots: id  0 | task 1464 | prompt processing progress, n_tokens = 55885, batch.n_tokens = 589, progress = 0.990851
slot update_slots: id  0 | task 1464 | n_tokens = 55885, memory_seq_rm [55885, end)
slot update_slots: id  0 | task 1464 | prompt processing progress, n_tokens = 56397, batch.n_tokens = 512, progress = 0.999929
slot update_slots: id  0 | task 1464 | created context checkpoint 7 of 32 (pos_min = 55884, pos_max = 55884, n_tokens = 55885, size = 62.813 MiB)
slot update_slots: id  0 | task 1464 | n_tokens = 56397, memory_seq_rm [56397, end)
reasoning-budget: activated, budget=2147483647 tokens
slot init_sampler: id  0 | task 1464 | init sampler, took 4.37 ms, tokens: text = 56401, total = 56401
slot update_slots: id  0 | task 1464 | prompt processing done, n_tokens = 56401, batch.n_tokens = 4
slot update_slots: id  0 | task 1464 | created context checkpoint 8 of 32 (pos_min = 56396, pos_max = 56396, n_tokens = 56397, size = 62.813 MiB)
srv  log_server_r: done request: POST /v1/messages 127.0.0.1 200
reasoning-budget: deactivated (natural end)
slot print_timing: id  0 | task 1464 | 
prompt eval time =   65949.79 ms / 15441 tokens (    4.27 ms per token,   234.13 tokens per second)
       eval time =    3639.91 ms /    87 tokens (   41.84 ms per token,    23.90 tokens per second)
      total time =   69589.71 ms / 15528 tokens
slot      release: id  0 | task 1464 | stop processing: n_tokens = 56487, truncated = 0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Discussion
&lt;/h2&gt;

&lt;p&gt;While the underlying model is important, most people undervalue the client. I used both Claude Code and Copilot CLI with the same underlying model, Claude Sonnet 4.6. I found Claude Code superior by far across several sessions.&lt;/p&gt;

&lt;p&gt;The move of most vendors toward subscriptions to benefit from recurring revenues make sense for them. For the customer, however, it's another question: once you stop paying, you lose access to the service.&lt;/p&gt;

&lt;p&gt;In the context of coding assistants, vendors justify it by cloud usage. Unfortunately, the per-token metering is quite opaque. If the vendor doesn't size their service properly, users get charged more. I don't think that's fair.&lt;/p&gt;

&lt;p&gt;Keeping Claude Code while hosting the model local is a great cost-savvy alternative. You only need to pay for the hardware once. Granted, it's slower, but it's a business model I prefer. If you have well-designed working autonomous agents, you can run them during the night anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;To go further:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://martinfowler.com/bliki/Datensparsamkeit.html" rel="noopener noreferrer"&gt;Datensparsamkeit&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.tokenoptimize.dev/guides/llm-token-optimization-strategies" rel="noopener noreferrer"&gt;LLM Token Optimization Strategies: The Complete Guide for 2026&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://letsdatascience.com/blog/tokenization-deep-dive-why-it-matters-more-than-you-think" rel="noopener noreferrer"&gt;Tokenization Deep Dive: Why It Matters More Than You Think&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents" rel="noopener noreferrer"&gt;Effective context engineering for AI agents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://unsloth.ai/docs/basics/claude-code" rel="noopener noreferrer"&gt;How to Run Local LLMs with Claude Code&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://huggingface.co/blog/moe" rel="noopener noreferrer"&gt;Mixture of Experts Explained&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://openrouter.ai/qwen/qwen3-coder-next" rel="noopener noreferrer"&gt;Qwen3 Coder Next&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://blog.frankel.ch/tokensparsamkeit-coding-assistants/" rel="noopener noreferrer"&gt;A Java Geek&lt;/a&gt; on May 10&lt;sup&gt;th&lt;/sup&gt;, 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>tokens</category>
      <category>budget</category>
      <category>agents</category>
    </item>
    <item>
      <title>Designing a team of agents</title>
      <dc:creator>Nicolas Fränkel</dc:creator>
      <pubDate>Thu, 07 May 2026 09:02:00 +0000</pubDate>
      <link>https://dev.to/nfrankel/designing-a-team-of-agents-j1b</link>
      <guid>https://dev.to/nfrankel/designing-a-team-of-agents-j1b</guid>
      <description>&lt;p&gt;I continue to experiment with AI in the context of software engineering. I'm fortunate that my team supports me in exploring different ways to improve our daily work. This week, I designed a team of &lt;strong&gt;autonomous&lt;/strong&gt; agents to implement features, from design to implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why autonomous agents?
&lt;/h2&gt;

&lt;p&gt;A long time ago, we were delighted when the IDE offered auto-completion. In the previous two years, things have changed. A lot.&lt;/p&gt;

&lt;p&gt;Coding assistants have become our primary interfaces for coding. We still use IDEs, at least I do. Yet, I had an IDE licensing issue two weeks ago, and I continued to code even without it. The assistant automatically compiles and tests after every change. While it was forced on me, I believe it could be a valuable test for seasoned programmers: can you replace your IDE with your coding assistant, or are they complementary?&lt;/p&gt;

&lt;p&gt;That being said, chatting with your assistant is but a step in the AI maturity level. In &lt;a href="https://www.bassimeledath.com/blog/levels-of-agentic-engineering" rel="noopener noreferrer"&gt;The 8 Levels of Agentic Engineering&lt;/a&gt;, the author mentions the following steps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Levels 1 &amp;amp; 2: Tab Complete and Agent IDE&lt;/li&gt;
&lt;li&gt;Level 3: Context Engineering&lt;/li&gt;
&lt;li&gt;Level 4: Compounding Engineering&lt;/li&gt;
&lt;li&gt;Level 5: MCP and Skills&lt;/li&gt;
&lt;li&gt;Level 6: Harness Engineering &amp;amp; Automated Feedback Loops&lt;/li&gt;
&lt;li&gt;Level 7: Background Agents&lt;/li&gt;
&lt;li&gt;Level 8: Autonomous Agent Teams&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;Claude Code's experimental Agent Teams feature is an early implementation: multiple instances work in parallel on a shared codebase, where teammates operate in their own context windows and communicate directly with each other. Anthropic used 16 parallel agents to build a C compiler from scratch that can compile Linux. Cursor ran hundreds of concurrent agents for weeks to build a web browser from scratch and migrate their own codebase from Solid to React.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Obviously, I have neither the resources nor the know-how to tackle such a huge undertaking. However, I wanted to design a team to handle smaller tasks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Subagents
&lt;/h2&gt;

&lt;p&gt;I recently wrote about &lt;a href="https://blog.frankel.ch/experimenting-ai-subagents/" rel="noopener noreferrer"&gt;subagents&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Claude describes several benefits of using subagents:&lt;/p&gt;

&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Preserve context&lt;/strong&gt; by keeping exploration and implementation out of your main conversation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enforce constraints&lt;/strong&gt; by limiting which tools a subagent can use&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reuse configurations&lt;/strong&gt; across projects with user-level subagents&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Specialize behavior&lt;/strong&gt; with focused system prompts for specific domains&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Control costs&lt;/strong&gt; by routing tasks to faster, cheaper models like Haiku&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;— &lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;Create custom subagents&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude Code provides several built-in subagents: explore, plan, general purpose, status line, and Claude Code guidelines. You can read more about each of them in the &lt;a href="https://code.claude.com/docs/en/sub-agents#built-in-subagents" rel="noopener noreferrer"&gt;documentation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;However, and this is where it gets interesting, you can define a specialized subagent through a dedicated Markdown file with a specific front matter in a &lt;code&gt;.claude/agents&lt;/code&gt; folder. The front matter defines: a name, a description, a model, and a list of available tools. The body describes the subagent's purpose, &lt;em&gt;i.e.&lt;/em&gt;, its instructions. Here's a sample:&lt;/p&gt;

&lt;blockquote&gt;

&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;code-reviewer&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Reviews&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;quality&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;best&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;practices"&lt;/span&gt;
&lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Read, Glob, Grep&lt;/span&gt;
&lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sonnet&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

You are a code reviewer. When invoked, analyze the code and provide
specific, actionable feedback on quality, security, and best practices.
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;— &lt;a href="https://code.claude.com/docs/en/sub-agents#write-subagent-files" rel="noopener noreferrer"&gt;Write subagent files&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The team design
&lt;/h2&gt;

&lt;p&gt;I asked Claude Code to come up with the team design. He created a plan that included five subagents: &lt;code&gt;planner&lt;/code&gt;, &lt;code&gt;challenger&lt;/code&gt;, &lt;code&gt;coder&lt;/code&gt;, &lt;code&gt;tester&lt;/code&gt;, and &lt;code&gt;documenter&lt;/code&gt;. Their name are pretty self-descriptive, but I'll come back to them later. In the meantime, I read that the optimal number of agents in a team is between three and five: I removed the &lt;code&gt;documenter&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;"Regular" subagents do their tasks autonomously, but then come back to the main agent. Subagents in teams communicate with each other directly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh517l72hrkb2f9dtgqur.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh517l72hrkb2f9dtgqur.png" width="800" height="304"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After defining agents, you need to specify how subagents communicate with each other toward the accomplishment of a task. You describe such interactions in &lt;a href="https://blog.frankel.ch/writing-agent-skill/" rel="noopener noreferrer"&gt;a skill&lt;/a&gt;, which Claude also created for me. Here's a very simplified model.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkb3krp0jqpojrtofl50c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkb3krp0jqpojrtofl50c.png" alt="Sample of agent interactions" width="800" height="393"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;States represent agent responsibilities in their respective agent file, while interactions represent communication between agents in the skill. Note that I added extra communication constraints within agent descriptions. I work as usual with Claude Code. The only difference is when it's time to implement; instead of telling it to proceed, I call the &lt;code&gt;/implement&lt;/code&gt; skill from the command line.&lt;/p&gt;

&lt;p&gt;Here's how it looks (at the moment) in the console:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;4 tasks (0 done, 4 open)
  ◻ Approve plan › blocked by #3
  ◻ Implement merged CSV hierarchy changes › blocked by #1
  ◻ Plan: materialize merged CSVs with new hierarchy across both repos
  ◻ Write tests for merged CSV changes › blocked by #2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Team agents beyond marketing
&lt;/h2&gt;

&lt;p&gt;Agents' teams are amazing, but they come with issues.&lt;/p&gt;

&lt;p&gt;The biggest one is the tension between autonomy and security. In regular Claude Code sessions, it's easy to grant permission when it asks for a command. With regular subagents, I notice it gets a bit more tedious: requests for permissions are much more frequent. Agents' teams reach a peak in that regard.&lt;/p&gt;

&lt;p&gt;To cope with that, you can add permissions in your &lt;code&gt;settings.json&lt;/code&gt; and hope they cover the commands made by Claude Code in the session. Alternatively, you can use the aptly-named &lt;code&gt;--dangerously-skip-permissions&lt;/code&gt; flag, or wait for the slightly safer &lt;a href="https://www.anthropic.com/engineering/claude-code-auto-mode" rel="noopener noreferrer"&gt;auto mode&lt;/a&gt;. In all cases, you must arbitrate between autonomy and security. Too much security slows you down, too much autonomy is risky.&lt;/p&gt;

&lt;p&gt;Also, agents' teams are experimental at the moment. They might be better in the future, be widely different, or not exist at all. To enable them now, set &lt;code&gt;CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS&lt;/code&gt; to &lt;code&gt;1&lt;/code&gt; in &lt;code&gt;settings.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On the plus side, it pays to invest time in agents. Within or without teams, you explicitly call agents on the command line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Autonomous agent teams sit at the top of the agentic engineering ladder. Getting there requires designing agent interactions upfront and solving the autonomy vs. security tradeoff.&lt;/p&gt;

&lt;p&gt;The feature is experimental, and I'd treat it as such, but the direction is clear. We are already spending less time coding directly and more time managing agents. Agents' teams are the next logical step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;To go further:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.bassimeledath.com/blog/levels-of-agentic-engineering" rel="noopener noreferrer"&gt;The 8 Levels of Agentic Engineering&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/agent-teams" rel="noopener noreferrer"&gt;Orchestrate teams of Claude Code sessions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/sub-agents#built-in-subagents" rel="noopener noreferrer"&gt;Built-in subagents&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://blog.frankel.ch/design-team-agents/" rel="noopener noreferrer"&gt;A Java Geek&lt;/a&gt; on May 3&lt;sup&gt;rd&lt;/sup&gt;, 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>agentskills</category>
      <category>ai</category>
    </item>
    <item>
      <title>Three Mastodon issues because of Cloudflare Bot protection</title>
      <dc:creator>Nicolas Fränkel</dc:creator>
      <pubDate>Thu, 30 Apr 2026 09:02:00 +0000</pubDate>
      <link>https://dev.to/nfrankel/three-mastodon-issues-because-of-cloudflare-bot-protection-1el3</link>
      <guid>https://dev.to/nfrankel/three-mastodon-issues-because-of-cloudflare-bot-protection-1el3</guid>
      <description>&lt;p&gt;I noticed some time ago that three &lt;a href="https://mastodon.top/@frankel" rel="noopener noreferrer"&gt;Mastodon&lt;/a&gt; features had stopped working on my blog. Each of them seemed like a separate problem, but they had the same root cause. In this blog post, I want to describe these issues and the simple fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Domain verification
&lt;/h2&gt;

&lt;p&gt;Mastodon allows you to prove that you own a domain. The mechanism requires two steps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Add a &lt;code&gt;&amp;lt;link rel="me"&amp;gt;&lt;/code&gt; tag in your pages, pointing to your Mastodon profile&lt;/li&gt;
&lt;li&gt;Add your website URL to your Mastodon profile&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When Mastodon crawls your page and finds the backlink, it displays a green checkmark next to your URL in your profile.&lt;/p&gt;

&lt;p&gt;I had set this up long ago and made it work, for my blog and GitHub. Then, the blog link stopped working, while GitHub's still worked. It made me sad, but I couldn't understand what changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Post preview
&lt;/h2&gt;

&lt;p&gt;When you share a link on Mastodon, your instance crawls the page to generate a preview card. It reads the page's &lt;a href="https://ogp.me/" rel="noopener noreferrer"&gt;OpenGraph&lt;/a&gt; tags: title, description, and image.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fakxgks8sojawwfnq9tl0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fakxgks8sojawwfnq9tl0.png" alt="Mastodon link preview" width="800" height="658"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;My blog had all the correct tags. Yet every blog post I shared appeared as a plain link with no card. However, all newsletters shared the same preview image, and they mysteriously worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attribution
&lt;/h2&gt;

&lt;p&gt;Mastodon introduced author attribution in the 4.3 version. If a page contains the following meta tag, Mastodon displays a "By @author" badge when someone else shares the link — and lets their followers follow you directly from the preview.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"fediverse:creator"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"@frankel@mastodon.top"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This had never worked for my blog since I added it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The culprit
&lt;/h2&gt;

&lt;p&gt;All three features share the same mechanism: Mastodon's server needs to crawl your page. I took me about 20 minutes of chatting with Claude Code to solve the three above issues, by solving the card preview one. The key was to understand that since the newsletter card preview worked, my blog wasn't at fault. Claude Code checked the response headers and understood the following.&lt;/p&gt;

&lt;p&gt;My blog runs behind &lt;a href="https://www.cloudflare.com/" rel="noopener noreferrer"&gt;Cloudflare&lt;/a&gt;. I had enabled &lt;strong&gt;Bot Fight Mode&lt;/strong&gt;, a Cloudflare feature that blocks automated traffic it deems suspicious. Services can register domains on Cloudflare to be recognized as good actors. In fact, Bluesky and Twitter have done it.&lt;/p&gt;

&lt;p&gt;However, Mastodon was betrayed by its decentralized nature. Each Mastodon instance runs a specific domain; mine is &lt;a href="https://mastodon.top" rel="noopener noreferrer"&gt;https://mastodon.top&lt;/a&gt;. mastodon.top's crawler runs on &lt;a href="https://www.hetzner.com/" rel="noopener noreferrer"&gt;Hetzner&lt;/a&gt; infrastructure. Hetzner IP addresses carry a high threat score in Cloudflare's database — they're a popular choice with bot operators.&lt;/p&gt;

&lt;p&gt;When Mastodon's crawler tried to fetch my pages, Cloudflare served it a JavaScript challenge instead of HTML. Mastodon's &lt;code&gt;http.rb&lt;/code&gt; client can't solve JavaScript challenges. It got no useful response, cached the failure, and moved on.&lt;/p&gt;

&lt;p&gt;The fix: turn off Bot Fight Mode in &lt;strong&gt;Security&lt;/strong&gt; &amp;gt; &lt;strong&gt;Bots&lt;/strong&gt; in the Cloudflare dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;This is a well-known issue in both the Mastodon and Cloudflare communities. For a public static blog, Bot Fight Mode offers minimal protection while actively breaking legitimate crawlers. Turning it off fixed all three Mastodon issues at once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;To go further:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://tsmith.co/2023/cloudflare-tunnels-and-mastodon/" rel="noopener noreferrer"&gt;Cloudflare Tunnels and Mastodon&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://community.cloudflare.com/t/bot-protection-is-blocking-valid-bots/287563" rel="noopener noreferrer"&gt;Bot protection is blocking valid bots&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://box464.com/posts/mastodon-preview-cards/" rel="noopener noreferrer"&gt;Understanding Mastodon Preview Card Display Logic&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://blog.frankel.ch/mastodon-cloudflare-bot-protection/" rel="noopener noreferrer"&gt;A Java Geek&lt;/a&gt; on April 26&lt;sup&gt;th&lt;/sup&gt;, 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>mastodon</category>
      <category>fediverse</category>
      <category>issue</category>
    </item>
  </channel>
</rss>
