<?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, a follow-up</title>
      <dc:creator>Nicolas Fränkel</dc:creator>
      <pubDate>Thu, 24 Sep 2026 09:02:00 +0000</pubDate>
      <link>https://dev.to/nfrankel/ai-assisted-genealogy-a-follow-up-531</link>
      <guid>https://dev.to/nfrankel/ai-assisted-genealogy-a-follow-up-531</guid>
      <description>&lt;p&gt;I got a lot of feedback on my post &lt;a href="https://blog.frankel.ch/ai-assisted-genealogy/" rel="noopener noreferrer"&gt;AI-assisted genealogy&lt;/a&gt;, some good, some not so good. In any case, I felt the subject was interesting to a lot of people. Meanwhile, I continue working on my tree, and I have deepened my understanding of the subject. In this post, I want to share again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trust, but verify
&lt;/h2&gt;

&lt;p&gt;Let's address the not-so-good feedback first. The gist of it was: AI can hallucinate. It's true in theory: when you ask a question, it provides any answer. The pushback is much less valid when the answer is grounded in data.&lt;/p&gt;

&lt;p&gt;The other feedback I had was that genealogy sites weren't trustworthy sources. It's a valid point, which I already acknowledged in the initial post.&lt;/p&gt;

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

&lt;p&gt;&lt;em&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;/em&gt;&lt;/p&gt;

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

&lt;p&gt;—- &lt;a href="https://blog.frankel.ch/ai-assisted-genealogy/#the_loop" rel="noopener noreferrer"&gt;The loop&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I realize that I could have been more detailed, so let me fix it.&lt;/p&gt;

&lt;p&gt;GEDCOM has the concept of quality, with the &lt;code&gt;QUAY&lt;/code&gt; directive:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The QUAY tag's value conveys the submitter's quantitative evaluation of the credibility of a piece of information, based upon its supporting evidence. Some systems use this feature to rank multiple conflicting opinions for display of most likely information first. It is not intended to eliminate the receiver's need to evaluate the evidence for themselves.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Quality&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;Unreliable evidence or estimated data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Questionable reliability of evidence (interviews, census, oral genealogies, or potential for bias for example, an autobiography)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Secondary evidence, data officially recorded sometime after event&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Direct and primary evidence used, or by dominance of the evidence&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;/blockquote&gt;

&lt;p&gt;The table defines guidelines, not precisely defined rules, so I interpreted them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Genealogy sites are &lt;code&gt;QUAY 2&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Official acts, whether civil or parish-based, are &lt;code&gt;QUAY 3&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The process is the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Mine the huge amount of data available on genealogy sites&lt;/li&gt;
&lt;li&gt;Set their &lt;code&gt;QUAY&lt;/code&gt; to 2&lt;/li&gt;
&lt;li&gt;Check the data is in an official record&lt;/li&gt;
&lt;li&gt;If yes, set the data's &lt;code&gt;QUAY&lt;/code&gt; to 3&lt;/li&gt;
&lt;li&gt;Otherwise, discard the data&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Hence, trust, but verify.&lt;/p&gt;

&lt;h2&gt;
  
  
  Archives and browsing skills
&lt;/h2&gt;

&lt;p&gt;In the original post, I mentioned you should create one skill "per site". As in the previous section, I may have been a bit concise. Let me detail:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One per genealogy site (Geni, Geneanet, etc.)&lt;/li&gt;
&lt;li&gt;One skill per archive site, whether city or department archives&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Archive sites can be roughly classified along two orthogonal axes.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Account: fully open access vs. required account&lt;/li&gt;
&lt;li&gt;Bot protection: I identified the 3 following levels.

&lt;ul&gt;
&lt;li&gt;At the lowest, the site is accessible with &lt;code&gt;curl&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Needs a full-fledged browser. For this, I added a small &lt;code&gt;uv&lt;/code&gt;-based Python project to steer &lt;a href="https://blog.frankel.ch/first-steps-playwright/" rel="noopener noreferrer"&gt;Playwright&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Cloudflare-protected (or similar). This is the highest protection level I faced. In some cases, even checking the "I'm a human" checkbox didn't work. To bypass it, I used Chrome in debug mode; I'll write a full-fledged post about it.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Automating account login
&lt;/h2&gt;

&lt;p&gt;I mentioned that some sites require you to be logged in. Of course, you could give your assistant access to your credentials, but that wouldn't be a smart thing to do.&lt;/p&gt;

&lt;p&gt;For the highest bot protection, there's no alternative but to start Chrome in debug mode, log in, and let the assistant steer the browser via the debug port. It's fragile: anything that closes the browser ends the run.&lt;/p&gt;

&lt;p&gt;For other sites, you should use Playwright with stored sessions. It goes like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Launch a browser with a persistent session&lt;/li&gt;
&lt;li&gt;Log in to the sites you want to use&lt;/li&gt;
&lt;li&gt;Close the browser. The profile has stored the authentication data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Later on, the assistant can start the browser within the context of the same session: the assistant will have the same access as the user who logged in. Session length depends on the exact site, so you should do it before an unattended run.&lt;/p&gt;

&lt;p&gt;The benefit over the debug approach is that it survives closing the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Leveraging genealogy sites
&lt;/h2&gt;

&lt;p&gt;My original advice was to mine genealogy sites with a premium subscription, then switch to official records. Since then, I have refined the approach.&lt;/p&gt;

&lt;p&gt;First, I have made it a cycle. After uncovering new ancestors in records, I went back to genealogy sites. In some cases, these ancestors were already part of others' trees, and I could jump a couple of generations again. Even if these later jumps are much smaller than the initial ones, it still means fewer tokens pinpointing the exact act in a record.&lt;/p&gt;

&lt;p&gt;These new ancestors don't automatically come into your tree. Most genealogy sites have the concept of a smart match. They run heuristics able to match (hence the name) people from unrelated trees. Some sites are better than others for this. You need to be &lt;strong&gt;very&lt;/strong&gt; careful when you accept a match, as it can corrupt a whole branch of your tree, by adding unrelated people. Hence, I tend to be very conservative. On the other hand, you can discover new spelling variants of your ancestors' names. Perhaps you missed an act because the assistant didn't search for the correct variant?&lt;/p&gt;

&lt;p&gt;Finally, most (all?) of these sites have two tiers: full data access and limited data access. Full access is unlocked during a trial period, either when you register or when you subscribe. Subscription can be expensive. Worse, some sites only offer a yearly subscription package. I had no qualms about registering email addresses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Leveraging Topola
&lt;/h2&gt;

&lt;p&gt;I still use &lt;strong&gt;and&lt;/strong&gt; love &lt;a href="https://github.com/PeWu/topola-viewer" rel="noopener noreferrer"&gt;Topola&lt;/a&gt;, the GEDCOM viewer.&lt;/p&gt;

&lt;p&gt;Among its features, it allows displaying a thumbnail for an &lt;code&gt;INDI&lt;/code&gt; in the tree. It works with the first &lt;code&gt;OBJE&lt;/code&gt; object associated with the &lt;code&gt;INDI&lt;/code&gt;. Other &lt;code&gt;OBJE&lt;/code&gt; are displayed in the right-side panel. If you have photographs of your ancestors, it's a nifty feature.&lt;/p&gt;

&lt;p&gt;I like order, so I follow these rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The profile picture must be a portrait. If I have no portrait, I crop an existing one to serve as such.&lt;/li&gt;
&lt;li&gt;All &lt;code&gt;OBJE&lt;/code&gt; that reference images are set in chronological order. Thus, the right panel displays them in the same order.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Transcriptions
&lt;/h2&gt;

&lt;p&gt;Transcriptions are essential. Acts are images, and you want their data to be easily accessible.&lt;/p&gt;

&lt;p&gt;From the beginning, I delegated the transcription process to the assistant. Your ability to decipher cursive script depends on several factors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The writing style of the clerk. The further back in time you go, the less standard the writing style is and the harder it is to decipher.&lt;/li&gt;
&lt;li&gt;Your familiarity with the language. Depending on your tree, you may have ancestors with acts in foreign languages.&lt;/li&gt;
&lt;li&gt;The act's age. The older the act, the harder it is to recognize some expressions, and the more the orthography is "fluid".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For these reasons, I started with transcriptions made by the assistant. I noticed it was quite careful sometimes: where I would have inferred a word, it decided to put a placeholder. It did the same for letters. I tried several alternatives.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;I asked for the best tool available and &lt;a href="https://app.transkribus.org/" rel="noopener noreferrer"&gt;Transkribus&lt;/a&gt;. It's a paid online tool, with lots of model choices. Some models are premium, others are free. It also gives you 50 free transcriptions a month. You just need to agree to wait in the queue, as runners for freeloaders are few.&lt;/p&gt;

&lt;p&gt;I tried 3 or 4 times after having exceeded my assistant quota. Results were abysmally bad, producing only gibberish, not even word soup. I read later that you had to train the model on your own document. It means you need to transcribe one or two pages of a record, and the results would improve over subsequent acts of the same record. Honestly, I won't use it again.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;To save my assistant tokens, I also tried a local setup. I leveraged &lt;a href="https://kraken.re/main/index.html" rel="noopener noreferrer"&gt;kraken&lt;/a&gt; with dedicated models depending on the language and the era. It was much better than Transkribus, but nowhere near the assistant capabilities. Whether I missed a configuration step or assistants have made huge leaps in ability, I'll never know. In the end, I just scrapped the skill.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A very good surprise is &lt;a href="https://www.filae.com/" rel="noopener noreferrer"&gt;Filae&lt;/a&gt;. It's a small French genealogy site. However, its transcription capabilities are second to none for French handwriting. I used it a couple of times with amazing results. The only issue is that it seems that they switched off this service. A pity.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Hiccups and hurdles
&lt;/h2&gt;

&lt;p&gt;Now that I've fleshed out more details about creating your family tree, here are some issues I have encountered, for fun. In no particular order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Three or even four generations of ancestors who lacked imagination and gave a child their first name. Now, you have a bunch of people in the same place with the same first name and last name.&lt;/li&gt;
&lt;li&gt;You get used to records being births, baptisms, marriages, deaths, or burials? What about a bit more variety, mixing some of them into the same one? Binary-searching for an act by date is now much more challenging.&lt;/li&gt;
&lt;li&gt;Mixed act types in a single record is just the beginning. It gets even better when dates aren't monotonic, &lt;em&gt;i.e.&lt;/em&gt;, in no order. It's the case for some notarial records I stumbled upon.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;I'm still working on the project, and now have more than 1200 people and span 13 generations on a branch. I hope this additional advice will be helpful if you choose to follow the same 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://gedcom.io/specifications/ged551.pdf" rel="noopener noreferrer"&gt;GEDCOM 5.5.1 Specification&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://playwright.dev/mcp/configuration/user-profile" rel="noopener noreferrer"&gt;Playwright: Profile &amp;amp; State&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://app.transkribus.org/" rel="noopener noreferrer"&gt;Transkribus&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kraken.re/main/index.html" rel="noopener noreferrer"&gt;kraken&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.filae.com/" rel="noopener noreferrer"&gt;Filae&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-follow-up/" rel="noopener noreferrer"&gt;A Java Geek&lt;/a&gt; on September 20&lt;sup&gt;th&lt;/sup&gt;, 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>genealogy</category>
      <category>claude</category>
      <category>feedback</category>
    </item>
    <item>
      <title>World geography gotchas</title>
      <dc:creator>Nicolas Fränkel</dc:creator>
      <pubDate>Thu, 17 Sep 2026 09:02:00 +0000</pubDate>
      <link>https://dev.to/nfrankel/world-geography-gotchas-5d9j</link>
      <guid>https://dev.to/nfrankel/world-geography-gotchas-5d9j</guid>
      <description>&lt;p&gt;Years ago, a colleague of mine told me about two pockets of foreign land surrounded by Switzerland: &lt;a href="https://osm.org/go/0Ck2o6xg-" rel="noopener noreferrer"&gt;Campione d'Italia&lt;/a&gt; and &lt;a href="https://osm.org/go/0C1ZlDk" rel="noopener noreferrer"&gt;Büsingen am Hochrhein&lt;/a&gt;, respectively Italy and Germany. Both are &lt;em&gt;enclaves&lt;/em&gt; of their respective country in Swiss territory:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;An enclave is a part of the territory of a state that is enclosed within the territory of another state. To distinguish the parts of a state entirely enclosed in a single other state, they are called true enclaves. A true enclave cannot be reached without passing through the territory of a single other state that surrounds it.&lt;/p&gt;

&lt;p&gt;—- &lt;a href="https://en.wikipedia.org/wiki/Enclave_and_exclave#True_enclaves" rel="noopener noreferrer"&gt;Enclave and exclave&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Enclaves can be seen from different perspectives. From a bird's-eye view, they are fun:&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%2Fu5kkx4gyiei6kykokn4b.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%2Fu5kkx4gyiei6kykokn4b.png" alt="Campione d'Italia" width="800" height="462"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If the enclave and its surrounding country are at odds (or worse), the life of its inhabitants can be hell. However, this post is about software, so let's focus on that. Imagine having to design systems for one of these two towns. Take Campione, for example. This is what you could have to deal with, depending on the domain you tackle:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Domain&lt;/th&gt;
&lt;th&gt;Quirk&lt;/th&gt;
&lt;th&gt;Impact on systems&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Taxation and customs&lt;/td&gt;
&lt;td&gt;Since 2020, Campione has been part of the EU customs territory, but it stays outside the EU VAT area. A local consumption tax applies instead, with Swiss VAT rates. Before 2020, the town was de facto in the Swiss customs territory.&lt;/td&gt;
&lt;td&gt;Online shops and accounting software need one exception for customs and another for tax.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Public services&lt;/td&gt;
&lt;td&gt;Italian law applies, but firefighters, ambulances, and healthcare come from Switzerland. The Carabinieri, the Italian police, provide security.&lt;/td&gt;
&lt;td&gt;Systems for the police, emergency services, and hospitals apply Italian law to services that Switzerland delivers.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Currency&lt;/td&gt;
&lt;td&gt;The Swiss franc is the main operating currency, though the euro is officially legal tender. Salaries are paid in Swiss francs.&lt;/td&gt;
&lt;td&gt;Payment and banking systems handle two currencies, and payroll crosses the border.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Address and geolocation&lt;/td&gt;
&lt;td&gt;Campione uses Swiss postal codes (CH-6911) and Swiss phone numbers (+41), except for the town hall, which has an Italian +39 number. Poste Italiane processes the mail.&lt;/td&gt;
&lt;td&gt;Address validation, shipping, and phone systems see a Swiss postal code and a Swiss prefix on an Italian address.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vehicle registration&lt;/td&gt;
&lt;td&gt;Vehicles must be registered in Italy, in Como, and carry Italian license plates. Some used to have Swiss Ticino plates, but this is no longer allowed.&lt;/td&gt;
&lt;td&gt;Plate validation fails if it derives the country from the address.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data privacy&lt;/td&gt;
&lt;td&gt;Campione falls under the GDPR and the Italian Privacy Code, but many services, such as healthcare and utilities, are Swiss. Data flows across the border.&lt;/td&gt;
&lt;td&gt;Any system that stores personal data falls under both Italian and Swiss privacy law.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Healthcare&lt;/td&gt;
&lt;td&gt;Residents can get Swiss hospital care through bilateral agreements, but insurance contracts must comply with Italian law.&lt;/td&gt;
&lt;td&gt;Health insurance systems check eligibility on the Swiss side and contracts on the Italian side.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Education&lt;/td&gt;
&lt;td&gt;Schools are Italian, but students may attend Swiss schools because they are closer.&lt;/td&gt;
&lt;td&gt;Student and scholarship systems enroll pupils in a school across the border and follow two curricula.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Enclaves are an extreme case, but gotchas are common.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://en.wikipedia.org/wiki/Livigno" rel="noopener noreferrer"&gt;Livigno&lt;/a&gt;, a few valleys east, is Italian and outside both the EU customs territory and the VAT area.&lt;/li&gt;
&lt;li&gt;The Canary Islands are Spanish and in the EU, but outside the VAT area. Currency, tax, customs, mail, phone, and law are separate attributes: none of them follows from a country code.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Wherever you work, chances are high that you'll have to deal with this kind of thing when designing software. There are &lt;a href="https://en.wikipedia.org/wiki/List_of_enclaves_and_exclaves#National_level" rel="noopener noreferrer"&gt;plenty of enclaves&lt;/a&gt; to start with. The real world is much more complex than we expect. Be sure to research first.&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://en.wikipedia.org/wiki/List_of_enclaves_and_exclaves#National_level" rel="noopener noreferrer"&gt;National enclaves that are also exclaves&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.wikipedia.org/wiki/Campione_d'Italia" rel="noopener noreferrer"&gt;Campione d'Italia&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.wikipedia.org/wiki/B%C3%BCsingen_am_Hochrhein" rel="noopener noreferrer"&gt;Büsingen am Hochrhein&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.frankel.ch/date-time-gotchas/" rel="noopener noreferrer"&gt;Date and time gotchas&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/world-geography-gotchas/" rel="noopener noreferrer"&gt;A Java Geek&lt;/a&gt; on September 13&lt;sup&gt;th&lt;/sup&gt;, 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>coding</category>
      <category>modeling</category>
      <category>geography</category>
      <category>gotchas</category>
    </item>
    <item>
      <title>Build an AI Agent (From Scratch)</title>
      <dc:creator>Nicolas Fränkel</dc:creator>
      <pubDate>Thu, 10 Sep 2026 09:02:00 +0000</pubDate>
      <link>https://dev.to/nfrankel/build-an-ai-agent-from-scratch-42e6</link>
      <guid>https://dev.to/nfrankel/build-an-ai-agent-from-scratch-42e6</guid>
      <description>&lt;p&gt;This review is about &lt;a href="https://www.manning.com/books/build-an-ai-agent-from-scratch?utm_source=frankel&amp;amp;utm_medium=affiliate&amp;amp;utm_campaign=affiliate&amp;amp;a_aid=frankel" rel="noopener noreferrer"&gt;Build an AI Agent (From Scratch)&lt;/a&gt; by Jungjun Hur and Younghee Song from Manning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Facts
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;10 chapters&lt;/li&gt;
&lt;li&gt;$29.29 (at the time of this writing)&lt;/li&gt;
&lt;li&gt;315 pages&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Chapters
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Building your first LLM agent

&lt;ol&gt;
&lt;li&gt;What is an AI agent?&lt;/li&gt;
&lt;li&gt;The brain of AI agents: LLMs&lt;/li&gt;
&lt;li&gt;Enabling actions: Tool use&lt;/li&gt;
&lt;li&gt;Implementing a basic ReAct agent&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;Developing advanced agent capabilities

&lt;ol&gt;
&lt;li&gt;Building knowledge bases with RAG&lt;/li&gt;
&lt;li&gt;Adding memory to your agent&lt;/li&gt;
&lt;li&gt;Planning and reflection for complex tasks&lt;/li&gt;
&lt;li&gt;Empowering agents with code execution&lt;/li&gt;
&lt;li&gt;Orchestrating multi-agent systems&lt;/li&gt;
&lt;li&gt;Evaluating agents&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Pros and cons
&lt;/h2&gt;

&lt;p&gt;At Manning, I consider the following two books to be references:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.manning.com/books/kubernetes-in-action-second-edition?utm_source=frankel&amp;amp;utm_medium=affiliate&amp;amp;utm_campaign=affiliate&amp;amp;a_aid=frankel" rel="noopener noreferrer"&gt;Kubernetes in Action&lt;/a&gt; by Marko Lukša. It's the book that taught me the foundations of Kubernetes. Fun fact: I got it from the author at a conference in exchange for Swiss chocolate.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.manning.com/books/api-design-patterns?utm_source=frankel&amp;amp;utm_medium=affiliate&amp;amp;utm_campaign=affiliate&amp;amp;a_aid=frankel" rel="noopener noreferrer"&gt;API Design Patterns&lt;/a&gt;. I read it while working on the Apache APISIX API gateway. At this time, I was deeply involved in (RESTful) APIs. I had read a lot on APIs; I had researched a lot; I held talks on APIs; I talked with lots of people about APIs. And yet, the book was a revelation, full of brilliant insights.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I think that Build an AI Agent is as fundamental to me as the other two were.&lt;/p&gt;

&lt;p&gt;While there are few chances that you will actually build an AI agent, this book teaches you the basics (and more) of how they operate. It starts with the agent loop, then proceeds to tools, then to MCP, etc.&lt;/p&gt;

&lt;p&gt;The only thing I would like to see for the next edition is for the developers to beef up the evaluating agents part.&lt;/p&gt;

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

&lt;p&gt;If you use AI agents and want to understand what happens when you use them, this is the book for you. I think it should belong in the library of any engineer worthy of the name.&lt;/p&gt;





&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://blog.frankel.ch/building-ai-agent-from-scratch/" rel="noopener noreferrer"&gt;A Java Geek&lt;/a&gt; on September 6&lt;sup&gt;th&lt;/sup&gt;, 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>books</category>
      <category>ai</category>
      <category>codingassistant</category>
      <category>learningbydoing</category>
    </item>
    <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>
  </channel>
</rss>
