<?xml version="1.0" encoding="utf-8"?><rss version="2.0"><channel><title>just what i find</title><link>https://blog.cynexia.com/</link><description>just what i find</description><item><title>Is the window open?</title><link>https://blog.cynexia.com/is-the-window-open</link><guid>https://blog.cynexia.com/is-the-window-open</guid><pubDate>Sat, 10 Oct 2026 08:02:46 +0000</pubDate><description>&lt;p&gt;I&amp;rsquo;m sure we&amp;rsquo;ve all been there - coat on, shoes on, just about to lock the door and - &amp;ldquo;did i leave the back window open?&amp;rdquo;&lt;/p&gt;&#10;&lt;p&gt;I wanted something which shows me, at a glance, which windows are open, or confirms they&amp;rsquo;re all closed.&#10;&lt;a href="https://blog.cynexia.com/attachments/2026-10-10%20Is%20the%20window%20open/IMG_7553.jpeg"&gt;&lt;img src="https://blog.cynexia.com/attachments/2026-10-10%20Is%20the%20window%20open/IMG_7553_hu_b4135749f4fd63d6.webp" srcset="https://blog.cynexia.com/attachments/2026-10-10%20Is%20the%20window%20open/IMG_7553_hu_b4135749f4fd63d6.webp 710w" sizes="(max-width: 800px) 100vw, 800px" width="710" height="1318" alt="E-paper display showing a Home Assistant window-status dashboard with two windows open: Front bed and Study, updated at 18:06." loading="lazy"&gt;&lt;/a&gt;&lt;/p&gt;&#10;&lt;h2 id="goals"&gt;goals&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;low maintenance - dont need to charge it every week&lt;/li&gt;&#10;&lt;li&gt;automatically updates in a few seconds&lt;/li&gt;&#10;&lt;li&gt;our windows can be &amp;ldquo;closed&amp;rdquo; or &amp;ldquo;latched closed&amp;rdquo; (with a ventilation space around), so support both&lt;/li&gt;&#10;&lt;li&gt;easy to glance at - no complex dashboards&lt;/li&gt;&#10;&lt;li&gt;small and unobtrusive.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="what-i-used"&gt;What i used&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Home Assistant Yellow&lt;/li&gt;&#10;&lt;li&gt;SNZB-04P/PR1 sensors&lt;/li&gt;&#10;&lt;li&gt;2.6&amp;quot; Hanshow Polaris Pro 280 Electronic Shelf Label&lt;/li&gt;&#10;&lt;li&gt;LilyGo T-Panel S3&lt;/li&gt;&#10;&lt;li&gt;OpenEPaperLink HA integration&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="window-sensors"&gt;Window sensors&lt;/h2&gt;&#10;&lt;p&gt;I wanted sensors with long battery life. I have a Home Assistant Yellow at home, which has Zigbee support. I already have several Zigbee plugs around the house. Most permanently powered Zigbee devices act as &amp;ldquo;routers&amp;rdquo; so there&amp;rsquo;s a strong network around the house. I decided to buy Sonoff SNZB-04P:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;CR2477 batteries&lt;/li&gt;&#10;&lt;li&gt;5+ years (claimed) battery life - although this is in a lab. but even half of this is fine for me.&lt;/li&gt;&#10;&lt;li&gt;small&lt;/li&gt;&#10;&lt;li&gt;cheap&#10;I ordered a bunch from aliexpress. I have a mix of SNZB-04P and SNZB-04PR1. The PR1 unit has a slightly slimmer body and a screw fix, vs a clip on the original. The magnet appears identical, and both look identical to Zigbee with the same firmware, model name and &amp;ldquo;ZHA quirk&amp;rdquo; (&lt;code&gt;zhaquirks.sonoff.snzb04p:(eWeLink / SNZB-04P)&lt;/code&gt;).&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;there&amp;rsquo;s also a SNZB-04PR2 version, but this isnt as good:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;2xAAA batteries&lt;/li&gt;&#10;&lt;li&gt;3 year claimed battery life&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/Koenkk/zigbee2mqtt/discussions/31618"&gt;there were reports&lt;/a&gt; of it occasionally missing the first state change after a long idle period which is not ideal&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="mounting-the-magnets"&gt;Mounting the magnets&lt;/h2&gt;&#10;&lt;p&gt;Fitting the sensors to the window is easy - just stick it on - but the magnet must be within 20mm of the sensor to trigger, so when the window is &amp;ldquo;latched&amp;rdquo; open it&amp;rsquo;s just out of reach. I designed a little 3d-printed mount to let me adjust the distance so that the magnet still sits in the sweet spot of the sensor even when the window is in the latched locked position. You can &lt;a href="https://makerworld.com/en/models/3347475-sonoff-snzb-04p-magnet-holder-parametric"&gt;download it from Makerworld&lt;/a&gt; - it&amp;rsquo;s parametric so it can be adjusted for whatever length you need. The magnet neatly clips in the top and it accepts 3M VHB tape in a little recess in the bottom.&#10;&lt;a href="https://blog.cynexia.com/attachments/2026-10-10%20Is%20the%20window%20open/window-contact-sensor.png"&gt;&lt;img src="https://blog.cynexia.com/attachments/2026-10-10%20Is%20the%20window%20open/window-contact-sensor_hu_7084a867136e31ea.webp" srcset="https://blog.cynexia.com/attachments/2026-10-10%20Is%20the%20window%20open/window-contact-sensor_hu_7084a867136e31ea.webp 800w, https://blog.cynexia.com/attachments/2026-10-10%20Is%20the%20window%20open/window-contact-sensor_hu_c20014b3db71167c.webp 1600w" sizes="(max-width: 800px) 100vw, 800px" width="800" height="600" alt="Close-up of a window fitted with a white wireless contact sensor and matching magnet, mounted on the frame and opening sash" loading="lazy"&gt;&lt;/a&gt;&lt;/p&gt;&#10;&lt;h2 id="electronic-shelf-labels"&gt;Electronic Shelf Labels?&lt;/h2&gt;&#10;&lt;p&gt;I was wandering around the supermarket and noticed that they had these little e-paper labels. These things are amazing - they&amp;rsquo;re cheap, hackable, and because e-paper consumes no battery between refreshes and the radios are asleep most of the time, they have really good battery life - with 2 updates per day, some are rated for up to 15 years! so even with a few updates a day, battery life should still be pretty good.&#10;The most common open source protocol and firmware for them is &lt;a href="https://openepaperlink.de/"&gt;OpenEPaperLink&lt;/a&gt;. Flashing the devices can be a bit fiddly - for some you need to cut a hole in the casing in exactly the right location - and getting the correct hardware revision can also be hit and miss, so rather than faf around, I bought some preflashed screens from &lt;a href="https://www.ebay.co.uk/usr/youtechitfromhere"&gt;J on eBay&lt;/a&gt;. They were UPSd to me in London in a couple of days.&#10;&lt;a href="https://blog.cynexia.com/attachments/2026-10-10%20Is%20the%20window%20open/epaper-tag-listings.png"&gt;&lt;img src="https://blog.cynexia.com/attachments/2026-10-10%20Is%20the%20window%20open/epaper-tag-listings_hu_8987be83fa9306e2.webp" srcset="https://blog.cynexia.com/attachments/2026-10-10%20Is%20the%20window%20open/epaper-tag-listings_hu_8987be83fa9306e2.webp 800w, https://blog.cynexia.com/attachments/2026-10-10%20Is%20the%20window%20open/epaper-tag-listings_hu_11fa280734f7d920.webp 1600w" sizes="(max-width: 800px) 100vw, 800px" width="800" height="261" alt="Online shop listings for several OpenEPaperLink-compatible e-ink display tags, including Hanshow Nebular and Polaris models with prices" loading="lazy"&gt;&lt;/a&gt;&lt;/p&gt;&#10;&lt;h2 id="openepaperlink-access-point"&gt;OpenEPaperLink Access Point&lt;/h2&gt;&#10;&lt;p&gt;The next component you need is an Access Point. The labels scan for a compatible OpenEPaperLink Access Point (AP) then associate with the first one they find. The OpenEPAperLink site has a bunch of recommendations, and you can hack one together with an ESP32-S3 and an ESP32-H2 and some spaghetti wiring, but i wanted something a bit more robust and ready to go, so i bought a &lt;a href="https://lilygo.cc/products/t-panel-s3"&gt;Lilygo T-Panel S3&lt;/a&gt; (make sure you dont get the &amp;ldquo;lite&amp;rdquo; version, this isnt compatible). This has both the ESP32s preconnected in a case with a screen and is fully supported by the OpenEPaperLink team. Once it arrived, i flashed it using the &lt;a href="https://install.openepaperlink.org/"&gt;installer&lt;/a&gt; - pay attention to the rotation of the RESET and BOOT buttons.&lt;/p&gt;&#10;&lt;h2 id="firmware-update-bug"&gt;Firmware update bug&lt;/h2&gt;&#10;&lt;p&gt;After installing, i wanted to update the firmware to the latest version using the built in firmware updater, but hit a defect in the firmware update process - it looks for an ESP32-C6, not H2. I &lt;a href="https://github.com/OpenEPaperLink/OpenEPaperLink/issues/621"&gt;logged the issue with the maintainers&lt;/a&gt; - workaround:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Download the contents of the &lt;a href="https://github.com/OpenEPaperLink/OpenEPaperLink/tree/master/binaries/ESP32-H2"&gt;H2 binaries folder from github&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;rename &lt;code&gt;firmware_H2.json&lt;/code&gt; to &lt;code&gt;firmware_C6_Uart0.json&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;In OpenEPaperLink go to Settings → Update → ESP32-H2 from file system → file system&lt;/li&gt;&#10;&lt;li&gt;Upload all the files in to the root of the file system&lt;/li&gt;&#10;&lt;li&gt;Return to Settings → Update and click &lt;strong&gt;Flash&lt;/strong&gt; beside ESP32-H2 from &lt;strong&gt;file system&lt;/strong&gt;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h2 id="waking-and-connecting-the-screens"&gt;Waking and connecting the screens&lt;/h2&gt;&#10;&lt;p&gt;This part&amp;rsquo;s pretty easy. The screens are put in to deep sleep for transport. To wake them just bring an NFC reader near. I found that just holding the top of my iphone near them did the trick.&#10;Once connected, they&amp;rsquo;ll start &amp;lsquo;searching&amp;rsquo; for an access point, and eventually they&amp;rsquo;ll find one and connect.&#10;&lt;a href="https://blog.cynexia.com/attachments/2026-10-10%20Is%20the%20window%20open/Screenshot%202026-09-30%20at%2018.36.26.webp"&gt;&lt;img src="https://blog.cynexia.com/attachments/2026-10-10%20Is%20the%20window%20open/Screenshot%202026-09-30%20at%2018.36.26_hu_daff1e312a36ebe6.webp" srcset="https://blog.cynexia.com/attachments/2026-10-10%20Is%20the%20window%20open/Screenshot%202026-09-30%20at%2018.36.26_hu_daff1e312a36ebe6.webp 800w, https://blog.cynexia.com/attachments/2026-10-10%20Is%20the%20window%20open/Screenshot%202026-09-30%20at%2018.36.26_hu_147b5d2b3bf1c0.webp 944w" sizes="(max-width: 800px) 100vw, 800px" width="800" height="566" alt="OpenEPaperLink access-point dashboard showing two active e-paper tags, including a window-status display." loading="lazy"&gt;&lt;/a&gt;&lt;/p&gt;&#10;&lt;h2 id="generating-the-screen"&gt;Generating the screen&lt;/h2&gt;&#10;&lt;p&gt;I created a small automation which generates the image using the &lt;a href="https://github.com/OpenEPaperLink/Home_Assistant_Integration/blob/main/docs/drawcustom/supported_types.md"&gt;&lt;code&gt;open_epaper_link.drawcustom&lt;/code&gt;&lt;/a&gt; action. You set the window entity IDs and display names in the &lt;code&gt;windows&lt;/code&gt; variable at the bottom and set them as triggers at the top:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;alias&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;E-paper - Window status portrait&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;triggers&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;trigger&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;state&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;entity_id&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;binary_sensor.back_bedroom_windows&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;binary_sensor.front_bedroom_windows&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;binary_sensor.study_windows&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;binary_sensor.kitchen_window&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#ae81ff"&gt;binary_sensor.living_room_windows&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;from&lt;/span&gt;: &lt;span style="color:#66d9ef"&gt;null&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;actions&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;variables&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;open_count&lt;/span&gt;: |-&lt;span style="color:#e6db74"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; {% set ns = namespace(n=0) %}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; {% for w in windows %}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; {% if is_state(w.entity_id, &amp;#39;on&amp;#39;) %}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; {% set ns.n = ns.n + 1 %}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; {% endif %}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; {% endfor %}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; {{ ns.n }}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;open_names&lt;/span&gt;: |-&lt;span style="color:#e6db74"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; {% set ns = namespace(x=[]) %}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; {% for w in windows %}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; {% if is_state(w.entity_id, &amp;#39;on&amp;#39;) %}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; {% set ns.x = ns.x + [w.name] %}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; {% endif %}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; {% endfor %}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; {{ ns.x | join(&amp;#39;\n&amp;#39;) }}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;action&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;open_epaper_link.drawcustom&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;target&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;device_id&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;596cd58872a5f26b58ad655d0d043e53&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;data&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;rotate&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;270&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;background&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;white&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;payload&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;type&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;text&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;value&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;WINDOWS&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;x&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;76&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;&amp;#39;y&amp;#39;&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;15&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;size&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;22&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;color&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;black&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;anchor&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;mt&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;type&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;line&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;x_start&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;12&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;x_end&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;140&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;y_start&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;42&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;y_end&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;42&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;width&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;2&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;fill&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;black&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;type&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;text&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;value&lt;/span&gt;: |-&lt;span style="color:#e6db74"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; {% if open_count | int == 0 %}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; ALL CLOSED&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; {% else %}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; {{ open_count }} OPEN&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#e6db74"&gt; {% endif %}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;x&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;76&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;&amp;#39;y&amp;#39;&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;60&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;size&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;24&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;color&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#39;{{ &amp;#34;black&amp;#34; if open_count | int == 0 else &amp;#34;red&amp;#34; }}&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;anchor&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;mt&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;type&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;text&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;value&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#39;{{ open_names }}&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;x&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;18&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;&amp;#39;y&amp;#39;&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;112&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;size&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;18&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;color&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;black&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;type&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;line&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;x_start&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;12&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;x_end&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;140&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;y_start&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;256&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;y_end&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;256&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;width&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;fill&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;black&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;type&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;text&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;value&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;Updated {{ now().strftime(&amp;#39;%H:%M&amp;#39;) }}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;x&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;76&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;&amp;#39;y&amp;#39;&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;274&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;size&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;14&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;color&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;black&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;anchor&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;mm&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;variables&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;windows&lt;/span&gt;:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;entity_id&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;binary_sensor.back_bedroom_windows&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;name&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;Back bed&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;entity_id&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;binary_sensor.front_bedroom_windows&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;name&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;Front bed&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;entity_id&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;binary_sensor.study_windows&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;name&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;Study&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;entity_id&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;binary_sensor.kitchen_window&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;name&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;Kitchen&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; - &lt;span style="color:#f92672"&gt;entity_id&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;binary_sensor.living_room_windows&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;name&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;Living room&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;mode&lt;/span&gt;: &lt;span style="color:#ae81ff"&gt;restart&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="whats-next"&gt;What&amp;rsquo;s next?&lt;/h2&gt;&#10;&lt;p&gt;I have a small display by the front door that tells me whether the windows are open or closed. Because it&amp;rsquo;s e-paper paired with long life sensors, hopefully i&amp;rsquo;m not going to be walking around changing batteries every week.&#10;It&amp;rsquo;d be good to see:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;which sensors are offline?&lt;/li&gt;&#10;&lt;li&gt;are the doors locked?&lt;/li&gt;&#10;&lt;li&gt;are the kids home (are the airtags from their keys somewhere in the house?)&lt;/li&gt;&#10;&lt;/ul&gt;&#10;</description></item><item><title>TRMNL on a Kindle</title><link>https://blog.cynexia.com/trmnl-on-a-kindle</link><guid>https://blog.cynexia.com/trmnl-on-a-kindle</guid><pubDate>Wed, 30 Sep 2026 14:12:38 +0100</pubDate><description>&lt;p&gt;I&amp;rsquo;ve &lt;a href="https://blog.cynexia.com/using-multiple-trmnl-mcp-api-keys-in-a-single-plugin-repo"&gt;written before&lt;/a&gt; about TRMNL - it&amp;rsquo;s a great project. During a recent cleanout, i found an old Kindle Voyage in the back of a drawer. As well as supporting their own devices, TRMNL offers a &amp;ldquo;BYOD&amp;rdquo; licence and, if you manage to get it &lt;a href="https://trmnl.com/byod"&gt;up and running on a device that nobody else has published before&lt;/a&gt;, they even give you back some of the licence fee!&#10;&lt;a href="https://blog.cynexia.com/attachments/2026-10-01%20TRMNL%20on%20a%20Kindle/IMG_7419.jpeg"&gt;&lt;img src="https://blog.cynexia.com/attachments/2026-10-01%20TRMNL%20on%20a%20Kindle/IMG_7419_hu_2eff119f8d7dd32d.webp" srcset="https://blog.cynexia.com/attachments/2026-10-01%20TRMNL%20on%20a%20Kindle/IMG_7419_hu_2eff119f8d7dd32d.webp 800w, https://blog.cynexia.com/attachments/2026-10-01%20TRMNL%20on%20a%20Kindle/IMG_7419_hu_e65a309c43cb21db.webp 1600w" sizes="(max-width: 800px) 100vw, 800px" width="800" height="600" alt="A Kindle Voyage on a windowsill displaying the TfL tube status dashboard. Central, Jubilee and Mildmay are all disrupted. A plant pot in the background." loading="lazy"&gt;&lt;/a&gt;&lt;/p&gt;&#10;&lt;h2 id="goals"&gt;goals&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Put a TRMNL in the hall to show the &lt;a href="https://trmnl.com/recipes/467801"&gt;Tube&lt;/a&gt;, &lt;a href="https://trmnl.com/recipes/234101"&gt;bus&lt;/a&gt; and &lt;a href="https://trmnl.com/recipes/466893"&gt;train&lt;/a&gt; status in the morning before we talk out of the door&lt;/li&gt;&#10;&lt;li&gt;reuse the Kindle to make a low-power TRMNL device&lt;/li&gt;&#10;&lt;li&gt;implement some sort of OTA updates, so i dont have to move it around to update it&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="jailbreaking-the-kindle"&gt;jailbreaking the kindle&lt;/h2&gt;&#10;&lt;p&gt;This was much easier than i thought it would be. The &lt;a href="https://kindlemodding.org/jailbreaking/"&gt;KindleModding&lt;/a&gt; site has an easy walk through - basically, you:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;plug the kindle in to the laptop and fill it with dummy data so that automatic updates dont get downloaded.&lt;/li&gt;&#10;&lt;li&gt;find the serial number, enter it in to the wizard which will help you pick the correct jailbreak&lt;/li&gt;&#10;&lt;li&gt;follow the instructions&#10;it was really easy. Took a little while, and i wasnt really sure it had finished until a new &amp;ldquo;Jailbroken&amp;rdquo; book appeared on the Kindle home screen.&#10;&lt;a href="https://blog.cynexia.com/attachments/2026-10-01%20TRMNL%20on%20a%20Kindle/IMG_7542.jpeg"&gt;&lt;img src="https://blog.cynexia.com/attachments/2026-10-01%20TRMNL%20on%20a%20Kindle/IMG_7542_hu_57041abe8c7a0d4.webp" srcset="https://blog.cynexia.com/attachments/2026-10-01%20TRMNL%20on%20a%20Kindle/IMG_7542_hu_57041abe8c7a0d4.webp 800w, https://blog.cynexia.com/attachments/2026-10-01%20TRMNL%20on%20a%20Kindle/IMG_7542_hu_abfe1d216ef67e2d.webp 1600w" sizes="(max-width: 800px) 100vw, 800px" width="800" height="1004" alt="Close-up of the Kindle home screen showing a newly added book titled “JAILBROKEN”." loading="lazy"&gt;&lt;/a&gt;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h2 id="attempt-1---koreader--trmnl-koreader"&gt;Attempt 1 - KOReader + TRMNL-KOReader&lt;/h2&gt;&#10;&lt;p&gt;&lt;a href="https://github.com/koreader/koreader"&gt;KOReader&lt;/a&gt; is a multi-platform ebook application based around &amp;ldquo;plugins&amp;rdquo;, and TRMNL provides the &lt;a href="https://github.com/usetrmnl/trmnl-koreader"&gt;TRMNL-KOReader&lt;/a&gt; plugin for it. TRMNL recommends this approach as they say it&amp;rsquo;s &amp;ldquo;easier&amp;rdquo;. I installed KOReader, and installed and it worked. But, there were a couple of annoyances - if you accidentally tap on the screen, TRMNL closes (which isnt great in a hallway or wherever), so I &lt;a href="https://github.com/usetrmnl/trmnl-koreader/pull/22"&gt;raised a PR&lt;/a&gt; to allow users to chose the exit mode (single tap, long press). I also &lt;a href="https://github.com/usetrmnl/trmnl-koreader/pull/23"&gt;raised a PR to enable taps to fetch or advance the current dashboard&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;But then i started to hit some more complex problems around wifi resilience and battery life. I spent a little time seeing if i could fix it, but it&amp;rsquo;s impossible to properly update the wifi lifecycle without changing KOReader and i didnt want that (it&amp;rsquo;s much more complex than i am comfortable working on). So after pushing my PRs, i abandoned this and moved to KUAL.&lt;/p&gt;&#10;&lt;h2 id="attempt-2-which-worked---kual--trmnl-kindle"&gt;Attempt 2 (which worked) - KUAL + TRMNL-Kindle&lt;/h2&gt;&#10;&lt;p&gt;KUAL (Kindle Unified Application Launcher) is a launcher and menu system for running 3rd party/custom applications and scripts on Kindles. To prove it out, i installed &lt;a href="https://github.com/usetrmnl/trmnl-kindle"&gt;TRMNL-Kindle&lt;/a&gt;, and this was pretty slick. I noticed that wifi recovery was a bit flakey - if it failed, it would end up permanently disabled - so i submitted a &lt;a href="https://github.com/usetrmnl/trmnl-kindle/pull/40"&gt;PR (which has since been merged) to handle this gracefully&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h2 id="support-remote-updates"&gt;Support remote updates&lt;/h2&gt;&#10;&lt;p&gt;There&amp;rsquo;s no over the air update mechanism for KUAL or TRMNL-Kindle. TRMNL-Kindle uses the hardware wake timer to suspend the device between refreshes - this is very energy efficient, but it means that the Kindle is unresponsive, so you can&amp;rsquo;t just tap the screen to quit TRMNL, and power cycling takes around 5 minutes. So i &lt;a href="https://github.com/usetrmnl/trmnl-kindle/pull/43"&gt;submitted another PR&lt;/a&gt; to detect when the device has been woken significantly before the expected hardware wake time. If it has, it presents a &amp;ldquo;tap to quit&amp;rdquo; button for 10 seconds (in case it was woken for another reason), and if the user taps (or presses a hardware button on a non-touch Kindle), it quits TRMNL and cleans up its timers etc.&lt;/p&gt;&#10;&lt;p&gt;With this done, i installed &lt;a href="https://wiki.mobileread.com/wiki/USBNetwork"&gt;Kindle USBNetwork&lt;/a&gt; which provides a Dropbear SSH server. After hardening to only accept public key authentication, i can now perform updates over the network without having to plug the Kindle in to my laptop.&lt;/p&gt;&#10;&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;&#10;&lt;p&gt;Well, i achieved my goals. I jailbroke Kindle Voyage, installed KUAL, TRMNL-Kindle, Kindle USBNetwork with Dropbear, and locked it down with SSH Key authentication. I gave back to the community with a few PRs closing the gaps i found. KOReader wasnt particually bad or broken - it just didnt do what i wanted. TRMNL-Kindle is working just fine.&lt;/p&gt;&#10;</description></item><item><title>Responsible disclosure: TRMNL MCP server leaks password fields to AI agents</title><link>https://blog.cynexia.com/responsible-disclosure-trmnl-mcp-server-leaks-password-fields-to-ai-agents</link><guid>https://blog.cynexia.com/responsible-disclosure-trmnl-mcp-server-leaks-password-fields-to-ai-agents</guid><pubDate>Wed, 16 Sep 2026 11:44:00 +0000</pubDate><description>&lt;p&gt;&lt;a href="https://trmnl.com/"&gt;TRMNL&lt;/a&gt; is an awesome open-source ePaper display that lets users show almost anything they want. Launched on Kickstarter, it’s now got thousands of plugins and users. I’ve &lt;a href="https://trmnl.com/recipes?user_id=28421"&gt;published a few plugins&lt;/a&gt; myself. A few weeks ago, TRMNL &lt;a href="https://help.trmnl.com/en/articles/14130438-ai-agent#h_1f68fdf82e"&gt;added an MCP server&lt;/a&gt;. This lets you use a local coding agent to do most of the grunt work. I wrote about &lt;a href="https://blog.cynexia.com/using-multiple-trmnl-mcp-api-keys-in-a-single-plugin-repo"&gt;how to set that up&lt;/a&gt; in another post.&lt;/p&gt;&#10;&lt;h2 id="secret-disclosure"&gt;Secret disclosure&lt;/h2&gt;&#10;&lt;p&gt;As soon as i’d set up the MCP server, i asked Claude to test it against a plugin i’d already built and deployed. The model called IntegrationsShowTool with no arguments. The response included:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;&amp;#34;custom_fields_values&amp;#34; =&amp;gt; {&#10;  &amp;#34;crs&amp;#34; =&amp;gt; &amp;#34;NSG&amp;#34;,&#10;  &amp;#34;api_key&amp;#34; =&amp;gt; &amp;#34;&amp;lt;my Rail Data Marketplace consumer key, in full&amp;gt;&amp;#34;,&#10;  &amp;#34;filter_crs&amp;#34; =&amp;gt; &amp;#34;FPK&amp;#34;&#10;}&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The &lt;code&gt;api_key&lt;/code&gt; field is &lt;a href="https://github.com/mnbf9rca/trmnl-plugins/blob/7796f726cfe40ed6a02ae771fc119fb793035a9d/national_rail_departures/settings.yml#L12"&gt;defined&lt;/a&gt; as &lt;code&gt;field_type: password&lt;/code&gt;. The &lt;code&gt;polling_url&lt;/code&gt; field, which the placeholder text suggests often carries an API key in the query string, is marked &lt;code&gt;encrypted: true&lt;/code&gt; and is also returned in full.&lt;/p&gt;&#10;&lt;p&gt;To their credit, when digging a little deeper i saw that the built in &lt;code&gt;oauth_client_secret&lt;/code&gt; field is absent from the settings output, so the server already masks at least one secret. The password-type custom fields and encrypted settings appear to have been missed.&lt;/p&gt;&#10;&lt;h2 id="where-is-my-secret-now"&gt;Where is my secret now?&lt;/h2&gt;&#10;&lt;p&gt;MCP tool output goes straight into the agent&amp;rsquo;s context. Claude Code writes every tool result to a transcript on disk, and the content is sent to the model provider with each turn. Other MCP clients log tool output too. A user who follows TRMNL’s AI Agent setup guide will therefore inadvertently copy any secret credentials into agent logs and third-party services without any warning, and like i said, this happened automatically on the first call to the default discovery tool exposed by the server i.e. the one which an agent is likely to run unprompted.&lt;/p&gt;&#10;&lt;h2 id="fix-return-set-for-configured-secret-values"&gt;Fix: return [set] for configured secret values&lt;/h2&gt;&#10;&lt;p&gt;I suggested to TRMNL that they update the server to redact any custom field with &lt;code&gt;field_type: password&lt;/code&gt; in IntegrationsShowTool output, instead returning an indicator (e.g. &lt;code&gt;&amp;quot;api_key&amp;quot; =&amp;gt; &amp;quot;[set]&amp;quot;&lt;/code&gt;) to help agents understand whether the configuration has been applied.&lt;/p&gt;&#10;&lt;p&gt;TRMNL’s reply thanked me for my report and explained that the deny list was built from the plugin&amp;rsquo;s built-in form schema, so user-defined custom fields never passed through the redaction code - a key lesson for MCP server authors is to ensure that redaction is applied consistently across all code paths. They also:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Ran a scan over the entire MCP surface identified which found one other tool that returned polling headers in full, a field that commonly carries a bearer token&amp;quot;.&lt;/li&gt;&#10;&lt;li&gt;Added additional redaction to error logging.&lt;/li&gt;&#10;&lt;li&gt;Added help text to the &lt;code&gt;polling_url&lt;/code&gt; field to warn authors to put keys in a custom &lt;code&gt;password&lt;/code&gt; field and reference them by variable.&lt;/li&gt;&#10;&lt;li&gt;Stated that they’ll notify all users who might have been affected to rotate affected secrets.&lt;/li&gt;&#10;&lt;li&gt;Pushed out a fix within 19 hours. Impressive.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="what-to-do-now"&gt;What to do now?&lt;/h2&gt;&#10;&lt;p&gt;I retested after they’d deployed their fix and as far as i can tell, it’s correct and complete. Anyone who connected their agent to the TRMNL MCP server before 5th September 2026 should rotate any secrets they had stored in their plugins, as the secrets are already exposed in your transcripts. Deleting the transcript is not sufficient as the key will have been sent to your model provider.&lt;/p&gt;&#10;&lt;h2 id="timeline"&gt;Timeline&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Friday, September 4, 2026 16:56: initial report to TRMNL’s security team.&lt;/li&gt;&#10;&lt;li&gt;Friday, September 4, 2026 23:40: report acknowledged&lt;/li&gt;&#10;&lt;li&gt;Saturday, September 5, 2026 11:23: TRMNL reply to confirm the validity of the finding and that they’ve published a fix.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;</description></item><item><title>Using multiple TRMNL MCP API keys in a single plugin repo</title><link>https://blog.cynexia.com/using-multiple-trmnl-mcp-api-keys-in-a-single-plugin-repo</link><guid>https://blog.cynexia.com/using-multiple-trmnl-mcp-api-keys-in-a-single-plugin-repo</guid><pubDate>Tue, 08 Sep 2026 11:23:00 +0000</pubDate><description>&lt;p&gt;&lt;a href="https://trmnl.com/"&gt;TRMNL&lt;/a&gt; is an awesome open-source ePaper display that lets users show almost anything they want. Launched on Kickstarter, it’s now got thousands of plugins and users. I’ve published a few plugins, including a &lt;a href="https://trmnl.com/recipes/234101"&gt;realtime TfL bus stop arrival time board&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;A few weeks ago, TRMNL &lt;a href="https://help.trmnl.com/en/articles/14130438-ai-agent#h_1f68fdf82e"&gt;added an MCP server&lt;/a&gt;. This lets you use a local coding agent to do most of the grunt work.&lt;/p&gt;&#10;&lt;h2 id="install-the-mcp-server-through-mcpjson"&gt;Install the MCP server through .mcp.json&lt;/h2&gt;&#10;&lt;p&gt;Using Claude Code, i created a simple &lt;code&gt;.mcp.json&lt;/code&gt; file which references the API key by environment variable. Place this in the root of your repo. As it contains no secrets, this is safe to commit:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;&amp;#34;mcpServers&amp;#34;&lt;/span&gt;: {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;&amp;#34;trmnl&amp;#34;&lt;/span&gt;: {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;&amp;#34;type&amp;#34;&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;http&amp;#34;&lt;/span&gt;,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;&amp;#34;url&amp;#34;&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;https://trmnl.com/mcp?api_key=${TRMNL_MCP_API_KEY}&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I previously wrote about &lt;a href="https://blog.cynexia.com/running-claude-code-under-op-run-breaks-resume"&gt;how to inject the environment variable at launch time so that Claude can access the MCP server&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h2 id="each-plugin-gets-its-own-mcp-api-key"&gt;Each plugin gets its own MCP API key&lt;/h2&gt;&#10;&lt;p&gt;The TRMNL MCP server is designed so that each plugin gets its own MCP API key, which i &lt;a href="https://blog.cynexia.com/updated-using-1password-and-direnv-to-store-developer-secrets"&gt;store in 1Password&lt;/a&gt;. But i store my plugins in a single “monorepo” - so my standard approach of just injecting at runtime wouldnt work because a single &lt;code&gt;.env.tpl&lt;/code&gt; at the root of the repo can only hold a single MCP API key, so i would have to manually edit that file every time i wanted to switch plugin.&lt;/p&gt;&#10;&lt;p&gt;The best approach seemed to be to store each MCP API key in a field in a single 1Password entry, and dynamically load the correct MCP API key for each claude session, but i was quickly forgetting the exact magic incantation (command).&lt;/p&gt;&#10;&lt;h2 id="store-a-envtpl-in-each-plugin-folder-and-reference-that"&gt;Store a .env.tpl in each plugin folder, and reference that&lt;/h2&gt;&#10;&lt;p&gt;Each plugin directory gets its own &lt;code&gt;.env.tpl&lt;/code&gt; which maps variable names to &lt;code&gt;op://&lt;/code&gt; references, but as there are no secrets it is safe to commit it to git. Each file includes that specific plugin&amp;rsquo;s MCP key under the same variable name, and i can add whatever other plugin-specific secrets i need. For example:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;# national_rail_departures/.env.tpl&#10;TRMNL_MCP_API_KEY=op://TRMNL/trmnl/mcp_api_key_national_rail_departures&#10;RAILDATA_API_KEY=op://TRMNL/raildata/1010-live-departure-board_consumer_key&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;and&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code class="language-she" data-lang="she"&gt;# simple_tube_status/.env.tpl&#10;TRMNL_MCP_API_KEY=op://TRMNL/trmnl/mcp_api_key_simple_tfl_status&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;.&lt;code&gt;mcp.json&lt;/code&gt; at the repo root reads &lt;code&gt;${TRMNL_MCP_API_KEY}&lt;/code&gt; from the environment, and that is injected through whichever template we load.&lt;/p&gt;&#10;&lt;p&gt;Now we can launch Claude with a single command:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;op run --no-masking --env-file&lt;span style="color:#f92672"&gt;=&lt;/span&gt;&lt;span style="color:#e6db74"&gt;&amp;#34;&amp;lt;plugin_folder&amp;gt;/.env.tpl&amp;#34;&lt;/span&gt; -- claude&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="create-a-shortcut-which-is-easy-to-remember"&gt;Create a shortcut which is easy to remember&lt;/h2&gt;&#10;&lt;p&gt;That’ll work, but like i said before, it’s hard to remember, so I added a simple shell script which takes the folder name, loads the &lt;code&gt;.env.tpl&lt;/code&gt; file from that folder, and injects the resolved secrets to the Claude process as environment variables. The script is saved as &lt;code&gt;bin/op_claude&lt;/code&gt; in the project folder.&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;#!/bin/sh&#10;# Usage: op_claude &amp;lt;plugin_dir&amp;gt; [claude args...]&#10;cd &amp;#34;$(dirname &amp;#34;$0&amp;#34;)/..&amp;#34; || exit 1&#10;plugin=&amp;#34;$1&amp;#34;&#10;if [ ! -f &amp;#34;$plugin/.env.tpl&amp;#34; ]; then&#10; echo &amp;#34;usage: op_claude &amp;lt;plugin_dir&amp;gt; [claude args...] (no $plugin/.env.tpl found)&amp;#34; &amp;gt;&amp;amp;2&#10; exit 2&#10;fi&#10;shift&#10;exec op run --no-masking --env-file=&amp;#34;$plugin/.env.tpl&amp;#34; -- claude &amp;#34;$@&amp;#34;&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;I also added a single line to my &lt;code&gt;.envrc&lt;/code&gt; to add the &lt;code&gt;bin&lt;/code&gt; folder to the current path, but only when we’re in the repo folder:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;PATH_add bin&#10;&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="launch-with-a-simple-command"&gt;Launch with a simple command&lt;/h2&gt;&#10;&lt;p&gt;You can now just type &lt;code&gt;op_claude&lt;/code&gt; to launch e.g.&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;claude&lt;/code&gt; launches Claude like normal, no API keys, no secrets. The MCP server fails to load.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;op_claude&lt;/code&gt; tells you how to use the tool&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;op_claude national_rail_departures&lt;/code&gt; launches Claude and injects &lt;code&gt;.env.tpl&lt;/code&gt; file from the &lt;code&gt;national_rail_departures&lt;/code&gt; folder&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;op_claude simple_tube_status --resume &amp;lt;session-id&amp;gt;&lt;/code&gt; launches Claude, injects &lt;code&gt;simple_tube_status/.env.tpl&lt;/code&gt;, and resumes session &lt;code&gt;&amp;lt;session-id&amp;gt;&lt;/code&gt;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Each session sees exactly the correct MCP API key with no manual editing required.&lt;/p&gt;&#10;</description></item><item><title>Fix: Claude Code with op run responds "No deferred tool marker found in the resumed session"</title><link>https://blog.cynexia.com/running-claude-code-under-op-run-breaks-resume</link><guid>https://blog.cynexia.com/running-claude-code-under-op-run-breaks-resume</guid><pubDate>Fri, 04 Sep 2026 17:08:20 +0000</pubDate><description>&lt;p&gt;To stop my API keys being sucked up by some random malware, I &lt;a href="https://blog.cynexia.com/updated-using-1password-and-direnv-to-store-developer-secrets"&gt;keep secrets in 1Password&lt;/a&gt; and inject them into commands at runtime with &lt;code&gt;op run&lt;/code&gt;. That works for almost everything, but when using Claude Code, i started getting a confusing error message.&lt;/p&gt;&#10;&lt;h2 id="injecting-mcp-api-keys-to-claude-code"&gt;Injecting MCP API keys to Claude Code&lt;/h2&gt;&#10;&lt;p&gt;Claude Code reads MCP server config from &lt;code&gt;.mcp.json&lt;/code&gt; and expands &lt;code&gt;${VAR}&lt;/code&gt; references from the environment.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;{&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;&amp;#34;mcpServers&amp;#34;&lt;/span&gt;: {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;&amp;#34;trmnl&amp;#34;&lt;/span&gt;: {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;&amp;#34;type&amp;#34;&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;http&amp;#34;&lt;/span&gt;,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#f92672"&gt;&amp;#34;url&amp;#34;&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;https://mcp-server.com/mcp?api_key=${MY_SECRET_MCP_API_KEY}&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; }&#10;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The API key is in a file. &lt;code&gt;.env.tpl&lt;/code&gt;:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;MY_SECRET_MCP_API_KEY=op://vault/item/mcp_api_key&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;To give Claude the API key without exporting it, I call it via &lt;code&gt;op run&lt;/code&gt;, but when attempting to resume a session, i got this:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;&amp;gt; op run --env-file=.env.tpl -- claude --resume &amp;lt;session-id&amp;gt;&#10;Error: No deferred tool marker found in the resumed session. Either the session was not deferred, the marker is stale (tool already ran), or it exceeds the tail-scan window. Provide a prompt to continue the conversation.&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Nothing in that message mentions a terminal tells me what’s happening. Is this 1Password? I found another report with &lt;a href="https://github.com/anthropics/claude-code/issues/6820"&gt;same root cause&lt;/a&gt; and a different message: &lt;code&gt;Input must be provided either through stdin or as a prompt argument when using --print&lt;/code&gt;, which is even more misleading because you never passed &lt;code&gt;--print&lt;/code&gt;.&lt;/p&gt;&#10;&lt;h2 id="op-run-masks-secrets-through-stdout"&gt;&lt;code&gt;op run&lt;/code&gt; masks secrets through stdout&lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;op run&lt;/code&gt; &lt;a href="https://www.1password.dev/cli/reference/commands/run#examples"&gt;masks secrets in the child&amp;rsquo;s output&lt;/a&gt; by default. To do that it has to read the child&amp;rsquo;s stdout through a pipe, meaning a child process no longer sees a terminal on stdout.&lt;/p&gt;&#10;&lt;p&gt;Claude Code treats a non-terminal stdout as a signal that it is being scripted (e.g. in CI), and switches to print mode. In print mode, &lt;code&gt;--resume&lt;/code&gt; without a prompt only works for a session that was paused on a deferred tool. A normal session was not, so it prints that error and exits. You can confirm the mechanism without Claude:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;❯ op run --env-file=.env.tpl -- sh -c &amp;#39;[ -t 1 ] &amp;amp;&amp;amp; echo tty || echo pipe&amp;#39;&#10;pipe&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Note that this only happens when the env file resolves at least one secret. With an empty env file there is nothing to mask, &lt;code&gt;op run&lt;/code&gt; skips the pipe, and the test passes.&lt;/p&gt;&#10;&lt;h2 id="turn-off-masking-to-fix-claude"&gt;Turn off masking to fix Claude&lt;/h2&gt;&#10;&lt;p&gt;The fix is simple - turn masking off with the &lt;code&gt;--no-masking&lt;/code&gt; argument:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;op run --no-masking --env-file=.env.tpl -- claude --resume &amp;lt;session-id&amp;gt;&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Run the same check and stdout is a terminal again:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;❯ op run --no-masking --env-file=.env.tpl -- sh -c &amp;#39;[ -t 1 ] &amp;amp;&amp;amp; echo tty || echo pipe&amp;#39;&#10;tty&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Masking is a plaintext-match backstop that cannot work on an interactive terminal UI anyway. So that i remember how to launch Claude, i have a simple wrapper script in the repo&amp;rsquo;s &lt;code&gt;bin/&lt;/code&gt;, added to &lt;code&gt;PATH&lt;/code&gt; by direnv, so &lt;code&gt;op_claude&lt;/code&gt; is the only thing I type:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;#!/bin/sh&#10;cd &amp;#34;$(dirname &amp;#34;$0&amp;#34;)/..&amp;#34; || exit 1&#10;exec op run --no-masking --env-file=.env.tpl -- claude &amp;#34;$@&amp;#34;&#10;&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="if-you-would-rather-not-use-op-run-at-all"&gt;If you would rather not use &lt;code&gt;op run&lt;/code&gt; at all&lt;/h2&gt;&#10;&lt;p&gt;The narrowest option resolves one secret with &lt;code&gt;op read&lt;/code&gt; and replaces the shell with Claude in the same process, so there is no pipe and no extra process:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;MY_KEY=&amp;#34;$(op read &amp;#39;op://vault/item/field&amp;#39;)&amp;#34; exec claude &amp;#34;$@&amp;#34;&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;That also limits Claude, and every subprocess it spawns, to the single secret it needs.&lt;/p&gt;&#10;</description></item><item><title>Recipe: Veggie smoked beans</title><link>https://blog.cynexia.com/recipe-veggie-smoked-beans</link><guid>https://blog.cynexia.com/recipe-veggie-smoked-beans</guid><pubDate>Fri, 28 Aug 2026 08:02:22 +0000</pubDate><description>&lt;h2 id="ingredients"&gt;Ingredients&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;1 tin baked beans&lt;/li&gt;&#10;&lt;li&gt;1 small white onion&lt;/li&gt;&#10;&lt;li&gt;1 clove garlic&lt;/li&gt;&#10;&lt;li&gt;1 tbsp Hendersons Relish&lt;/li&gt;&#10;&lt;li&gt;splash of vegetable oil&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="method"&gt;Method&lt;/h2&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;finely chop the onion&lt;/li&gt;&#10;&lt;li&gt;grate the garlic&lt;/li&gt;&#10;&lt;li&gt;gently (low temperature) fry onion in the vegetable oil for 5-6 mins over a medium heat in the small (metal?) roasting tin you’ll use in the smoker&lt;/li&gt;&#10;&lt;li&gt;Take off the heat and add the grated garlic&lt;/li&gt;&#10;&lt;li&gt;Add the beans and hendersons&lt;/li&gt;&#10;&lt;li&gt;Stir and put in the smoker (110-120c) for 60-90 minutes. Check after 60 mins to make sure not dried out. If it starts to get dry, add a splash of water. Stir.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;</description></item><item><title>Recipe: Spicy cheese quesadillas</title><link>https://blog.cynexia.com/recipe-spicy-cheese-quesadillas</link><guid>https://blog.cynexia.com/recipe-spicy-cheese-quesadillas</guid><pubDate>Tue, 25 Aug 2026 08:05:01 +0000</pubDate><description>&lt;h2 id="ingredients"&gt;ingredients&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;20g cheddar&lt;/li&gt;&#10;&lt;li&gt;20g light cream cheese&lt;/li&gt;&#10;&lt;li&gt;1 tsp paprika&lt;/li&gt;&#10;&lt;li&gt;1/2 tsp chilli flakes&lt;/li&gt;&#10;&lt;li&gt;30g tinned sweetcorn, drained well&lt;/li&gt;&#10;&lt;li&gt;One of:&#10;&lt;ul&gt;&#10;&lt;li&gt;1/2 tsp miso paste&lt;/li&gt;&#10;&lt;li&gt;1 tsp soy sauce&lt;/li&gt;&#10;&lt;li&gt;1 tsp Hendersons relish&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;/li&gt;&#10;&lt;li&gt;1/4 tsp garlic powder&lt;/li&gt;&#10;&lt;li&gt;1/4 tsp onion powder&lt;/li&gt;&#10;&lt;li&gt;pinch white pepper&lt;/li&gt;&#10;&lt;li&gt;1 small tortilla&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="method"&gt;Method&lt;/h2&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Mix cream cheese &amp;amp; miso/soy/Henderson’s + spices into a paste&lt;/li&gt;&#10;&lt;li&gt;Fold in sweetcorn and cheddar&lt;/li&gt;&#10;&lt;li&gt;Spread in tortilla and fold in half&lt;/li&gt;&#10;&lt;li&gt;Cook in pan, place baking parchment on top and plate on that to squash slightly&lt;/li&gt;&#10;&lt;/ol&gt;&#10;</description></item><item><title>Recipe: Smoked Tempeh</title><link>https://blog.cynexia.com/recipe-smoked-tempeh</link><guid>https://blog.cynexia.com/recipe-smoked-tempeh</guid><pubDate>Thu, 20 Aug 2026 08:00:38 +0000</pubDate><description>&lt;p&gt;Smoked tempeh is excellent for smoking as the dense, porous structure absorbs smoke like few other proteins.&lt;/p&gt;&#10;&lt;h2 id="ingredients"&gt;Ingredients&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;30ml soy sauce&lt;/li&gt;&#10;&lt;li&gt;3 grams (about 1-2 cloves) minced garlic&lt;/li&gt;&#10;&lt;li&gt;15 g sesame oil&lt;/li&gt;&#10;&lt;li&gt;15ml rice vinegar - needed to penetrate&lt;/li&gt;&#10;&lt;li&gt;pinch of salt&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="method"&gt;Method&lt;/h2&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Slice into planks or thick strips rather than cubes — more surface area, better bark formation, less risk of drying out.&lt;/li&gt;&#10;&lt;li&gt;Steam or simmer the tempeh for 10-15 minutes first. This removes bitterness and opens the pores for better smoke and marinade absorption.&lt;/li&gt;&#10;&lt;li&gt;Marinate for at least 30 minutes. This gives time for the marinade to to infuse.&lt;/li&gt;&#10;&lt;li&gt;Smoke at 110-130°C for 45-60 minutes.&lt;/li&gt;&#10;&lt;li&gt;Baste or re-glaze halfway through (or more often if you like) if using a sticky marinade.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;</description></item><item><title>Recipe: Smoked Roast Potatoes</title><link>https://blog.cynexia.com/recipe-smoked-roast-potatoes</link><guid>https://blog.cynexia.com/recipe-smoked-roast-potatoes</guid><pubDate>Fri, 14 Aug 2026 07:52:08 +0000</pubDate><description>&lt;h2 id="ingredients"&gt;Ingredients&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;as many New Potatoes as you think you&amp;rsquo;ll need&lt;/li&gt;&#10;&lt;li&gt;Vegetable oil&lt;/li&gt;&#10;&lt;li&gt;salt, pepper&lt;/li&gt;&#10;&lt;li&gt;and a smoker&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="method"&gt;Method&lt;/h2&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Boil new potatoes until fork-tender (15-20 min) then drain&lt;/li&gt;&#10;&lt;li&gt;smash them on a sheet tray to break them slightly. The fluffy bits go crispy.&lt;/li&gt;&#10;&lt;li&gt;Coat them in oil/fat (e.g. drizzle in vegetable oil) and seasoning (salt, pepper)&lt;/li&gt;&#10;&lt;li&gt;roast in the oven at 220°C for 20-25 minutes until the edges are crispy and golden.&lt;/li&gt;&#10;&lt;li&gt;Transfer to the smoker at 110-130°C for 20-30 minutes to pick up smoke flavour. The already-crisped surfaces absorb smoke well. Going longer than 30 minutes risks drying them out or making the smoke flavour acrid.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;</description></item><item><title>Updated: using 1Password and direnv to store developer secrets</title><link>https://blog.cynexia.com/updated-using-1password-and-direnv-to-store-developer-secrets</link><guid>https://blog.cynexia.com/updated-using-1password-and-direnv-to-store-developer-secrets</guid><pubDate>Mon, 10 Aug 2026 09:33:56 +0000</pubDate><description>&lt;p&gt;I &lt;a href="https://blog.cynexia.com/using-1password-and-direnv-to-store-developer-secrets"&gt;previously&lt;/a&gt; wrote about how i&amp;rsquo;d implemented a combination of 1Password&amp;rsquo;s CLI and direnv to avoid storing passwords on disk. This works great, most of the time. The problem comes when i want to add a secret and i&amp;rsquo;m in the middle of a complex multi-agent task - because the environment is loaded once before Claude or Codex or whatever starts, adding or rotating credentials during a build (e.g. if the agent needs an API key to a new service, or accidentally exposes a secret to its context) is tricky. So i&amp;rsquo;ve evolved the system slightly.&lt;/p&gt;&#10;&lt;h2 id="whats-changed"&gt;What&amp;rsquo;s changed&lt;/h2&gt;&#10;&lt;p&gt;The main difference is that i now create a &lt;a href="https://www.1password.dev/service-accounts/get-started"&gt;1PW Service Account&lt;/a&gt; per project. This service account has read only access to the vault for a single project, and is loaded by direnv in to &lt;code&gt;OP_SERVICE_ACCOUNT_TOKEN&lt;/code&gt;. All other secrets are injected at runtime using &lt;code&gt;op run&lt;/code&gt;.&lt;/p&gt;&#10;&lt;h2 id="initial-setup---follow-most-setup-steps-from-the-original-post"&gt;Initial setup - follow most setup steps from the original post&lt;/h2&gt;&#10;&lt;p&gt;Initial setup remains the same - install 1PW, create a vault per project, add secrets to the vault.&lt;/p&gt;&#10;&lt;h2 id="create-a-service-account"&gt;Create a Service Account&lt;/h2&gt;&#10;&lt;p&gt;This is the bit i found trickiest - mainly because it&amp;rsquo;s so hard to figure out where you need to create the actual account! You can&amp;rsquo;t do it in the desktop app, you need to &lt;a href="https://my.1password.com/developer-tools/active/service-accounts?limit=25"&gt;go to the website&lt;/a&gt;. Once you log in, click Developer on the left. Create a new service account for the project.&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;You cannot change the grants assigned to a Service Account after creating it - but that&amp;rsquo;s ok, you can just create a new one.&lt;/li&gt;&#10;&lt;li&gt;give it &lt;code&gt;read only&lt;/code&gt; access to &lt;strong&gt;the vault for the project&lt;/strong&gt;, no other resources.&lt;/li&gt;&#10;&lt;li&gt;Store the key in 1Password - i store it in the vault of the project itself. There&amp;rsquo;s zero additional security implications of having the key there. It needs to live in the environment anyway! Let&amp;rsquo;s assume you save it in &lt;code&gt;op://project/1pw/service_account_key&lt;/code&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="create-envrc-to-load-the-service-account"&gt;Create &lt;code&gt;.envrc&lt;/code&gt; to load the Service Account&lt;/h2&gt;&#10;&lt;p&gt;very simple. This loads the service account token from the vault. This will require biometrics - but after this, all &lt;code&gt;op&lt;/code&gt; calls will use this token, so no longer require biometrics.&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;export OP_SERVICE_ACCOUNT_TOKEN=&amp;#34;$(op read &amp;#39;op://project/1pw/service_account_key&amp;#39;)&amp;#34;&#10;&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="run-direnv-allow"&gt;Run &lt;code&gt;direnv allow&lt;/code&gt;&lt;/h2&gt;&#10;&lt;p&gt;This authorizes&lt;/p&gt;&#10;&lt;h2 id="create-envtpl-in-the-project-folder"&gt;Create &lt;code&gt;.env.tpl&lt;/code&gt; in the project folder&lt;/h2&gt;&#10;&lt;p&gt;All our secrets are listed here. They&amp;rsquo;re only evaluated when we need them.&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;export MY_SECRET=&amp;#34;op://project/service/secret&amp;#34;&#10;export OTHER_SECRET=&amp;#34;op://project/other_service/secret&amp;#34;&#10;&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="run-your-app-using-op-run"&gt;Run your app using &lt;code&gt;op run&lt;/code&gt;&lt;/h2&gt;&#10;&lt;p&gt;You (or your agent) can now use 1PW any time to inject the environment e.g.&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;exec op run --env-file &amp;#34;.env.tpl&amp;#34; -- &amp;lt;your command&amp;gt;&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;I might run Fastlane&amp;hellip;&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;exec op run --env-file &amp;#34;.env.tpl&amp;#34; -- bundle exec fastlane beta&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;You could even have different template files for different parts of the app (e.g. in a monorepo), as long as the secrets are all in the same 1PW vault.&lt;/p&gt;&#10;&lt;p&gt;Overall this has simplified things for me significantly - I can add new credentials, or rotate existing ones, without needing to teardown my multiagent session.&lt;/p&gt;&#10;</description></item><item><title>Recpie: Miso BBQ Aubergine</title><link>https://blog.cynexia.com/recpie-miso-bbq-aubergine</link><guid>https://blog.cynexia.com/recpie-miso-bbq-aubergine</guid><pubDate>Sun, 09 Aug 2026 07:51:39 +0000</pubDate><description>&lt;h2 id="glaze"&gt;glaze&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;30g white or red miso (white = sweeter/milder, red = deeper/saltier)&lt;/li&gt;&#10;&lt;li&gt;15ml soy sauce&lt;/li&gt;&#10;&lt;li&gt;15ml mirin (or 10ml rice vinegar + small pinch sugar)&lt;/li&gt;&#10;&lt;li&gt;10g grated garlic&lt;/li&gt;&#10;&lt;li&gt;10g sesame oil&lt;/li&gt;&#10;&lt;li&gt;15ml honey&lt;/li&gt;&#10;&lt;li&gt;optional: 5g grated ginger&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="method"&gt;Method&lt;/h2&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Halve aubergines lengthways. Score the cut face in a crosshatch ~1cm deep without piercing the skin. This speeds cooking and lets glaze penetrate.&lt;/li&gt;&#10;&lt;li&gt;Lightly oil the cut face only. Do not soak it — aubergine absorbs oil indefinitely. Use high temperature oil (vegegable or avocado oil, not olive)&lt;/li&gt;&#10;&lt;li&gt;Grill cut-side down over medium heat 6-8 minutes until charred and the flesh is collapsing soft. Flip to skin-side down for another 4-6 minutes.&lt;/li&gt;&#10;&lt;li&gt;Test doneness: the flesh should be fully translucent and yielding throughout, no white spongy core.&lt;/li&gt;&#10;&lt;li&gt;Brush glaze onto the cut face. Return cut-side down over medium-low heat 2-3 minutes to caramelise. Watch closely — it scorches quickly.&lt;/li&gt;&#10;&lt;li&gt;Re-glaze and finish skin-side down 1-2 minutes to set the lacquer without burning.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;</description></item><item><title>Minimum Viable Solution Architecture</title><link>https://blog.cynexia.com/minimum-viable-solution-architecture</link><guid>https://blog.cynexia.com/minimum-viable-solution-architecture</guid><pubDate>Sun, 10 May 2026 16:16:52 +0000</pubDate><description>&lt;p&gt;Recently at work, someone asked me why my team wasn’t creating detailed logical data models as part of their solution design. It seems quite simple to me – they don’t do it because nobody reads it. Most solution architecture documentation is out of date before it even gets logged in the architecture repository.&lt;/p&gt;&#10;&lt;h2 id="why-do-we-even-write-documentation"&gt;Why do we even write documentation?&lt;/h2&gt;&#10;&lt;p&gt;I think we create documentation for one of two reasons:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;strong&gt;Collaborate now:&lt;/strong&gt; as soon as there is a second person in a team, they need to start talking so that they can work together. The more people, the harder this is (which is why we try to limit teams to half a dozen people or so). Documentation is a great way for people to collaborate. When things are written down, everyone can see it, and by adding comments or questions we get to a resolved version.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Communicate with the future:&lt;/strong&gt; We make decisions today which are the best available given the information we have at the time. Sometimes we forget what we decided, or why, and documentation gives us that. It’s not about proving whether a decision was right or wrong – that’s largely irrelevant – it’s about understanding what led us to that decision in the first place. Perhaps the reasons are still valid and we just forgot what they were. Or perhaps information we have now changes things.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;So really, design documentation is about &lt;em&gt;ensuring that everyone’s on the same page, both now and in the future&lt;/em&gt;. Some designs are ephemeral – just to help us get our heads around what we want to do, and some is persistent – giving us a framework on which to hang our future plans.&lt;/p&gt;&#10;&lt;h2 id="minimum-viable-solution-architecture"&gt;Minimum Viable (Solution) Architecture&lt;/h2&gt;&#10;&lt;p&gt;Given this framing, I think that good solution architecture contains four types of artefact. Not every solution needs all of them.&lt;/p&gt;&#10;&lt;h3 id="a-lightweight-specification"&gt;A lightweight specification&lt;/h3&gt;&#10;&lt;p&gt;This one is slightly controversial, as plenty of teams skip this and rely purely on tickets in the backlog, but a short, high level spec adds a framework to hang those tickets off. It doesn’t have to be complete – it will evolve over time – but does need to help everyone understand common questions:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;What are we building? Why?&lt;/li&gt;&#10;&lt;li&gt;What are the core goals and – often more important – what non-goals are we leaving out?&lt;/li&gt;&#10;&lt;li&gt;What does “quality” look like? How resilient does it need to be? What sort of performance envelope are we targeting? How many users do we expect?&lt;/li&gt;&#10;&lt;li&gt;Are there any constraints on the implementation – such as interoperability with other systems, regulatory requirements, time dependencies that drive sequencing etc.&lt;/li&gt;&#10;&lt;li&gt;Finally, are there any non-obvious assumptions baked in? It’s really hard to know what you’re assuming when you’re assuming it, and so this usually comes out during the peer review.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h3 id="a-box-or-context-diagram-or-two"&gt;A box or context diagram or two&lt;/h3&gt;&#10;&lt;p&gt;How does this solution interact with others around it, and if it’s particularly large or complex, how do the parts of this solution relate to others? Typically, I’d look to see a diagram with boxes representing services or applications, lines between them annotated with the type of data being transferred (e.g. “trade” or “order”). Nothing complex. If you wanted to, you could use a structured format like C4, but that’s not necessary – my team usually create these in Figma.&lt;/p&gt;&#10;&lt;h3 id="sequence-diagrams-or-flow-charts"&gt;Sequence diagrams or flow charts&lt;/h3&gt;&#10;&lt;p&gt;Only needed where temporal or logical ordering is non-obvious or where unrecoverable failure modes are likely to be missed, these diagrams help developers and architects understand complex orders of events. But these diagrams can be confusing or feel “ivory tower” – so if you can understand what needs to be done without one, don’t include one.&lt;/p&gt;&#10;&lt;h3 id="architecture-decision-records"&gt;Architecture Decision Records&lt;/h3&gt;&#10;&lt;p&gt;The format is less important than the process. Ideally, these exist at two levels. Organisational ADRs capture things like a design approval (what was the version of the design, who was part of the review, any comments or follow up actions). A solution ADR is created to record specific design decisions. A couple of examples:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Send notifications via email rather than SMS, even though SMS has higher open rates, as the team only has budget for a single channel and email provides functionality used elsewhere in the solution. Later, when the project grows or budgets change, the ADR explains the context behind the decision.&lt;/li&gt;&#10;&lt;li&gt;Write to two independent Azure ZRS blob stores in different regions rather than rely on GRS because GRS replicates asynchronously with no guaranteed maximum replication lag and we need to guarantee an RPO of 0. In this case, the ADR justifies what would otherwise look like overly complex engineering.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="what-not-to-include"&gt;What not to include&lt;/h2&gt;&#10;&lt;p&gt;There’s plenty of stuff we leave out. The big ones, which my team often get challenged on, are:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Schemas, such as OpenAPI specs, Kafka schemas etc.&lt;/strong&gt; – while we might brainstorm the start of these in a design, particularly to start fleshing out the RESTful state model, they go stale very quickly. The authoritative version is always the interface contract itself as written in code. And because this is our public interface, any changes must, by team convention, go through proper peer review.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Class diagrams or physical data models&lt;/strong&gt; – we certainly aren’t creating UML class diagrams in 2026. But database schemas also evolve over time. Don’t get me wrong – data architects absolutely should be working with engineers to ensure that the physical data model is correct. But that’s a versioned artefact implemented as code.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Detailed requirements and specifications&lt;/strong&gt; – in most projects, this is best handled feature by feature in the backlog.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="so-whats-the-minimum"&gt;So what’s the minimum?&lt;/h2&gt;&#10;&lt;p&gt;The minimum design I would expect to be asked to review would be a written description of the system or service, and a context diagram showing how that service is going to fit into the wider landscape – if there are no non-obvious decisions, then there are no solution ADRs. Once it’s approved, we’ll create the organisational ADR to keep track.&lt;/p&gt;&#10;</description></item><item><title>Using 1Password and direnv to store developer secrets</title><link>https://blog.cynexia.com/using-1password-and-direnv-to-store-developer-secrets</link><guid>https://blog.cynexia.com/using-1password-and-direnv-to-store-developer-secrets</guid><pubDate>Mon, 06 Apr 2026 16:48:00 +0000</pubDate><description>&lt;p&gt;This is more notes to myself so i don&amp;rsquo;t forget. Goals:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;no secrets stored on the filesystem (so they can&amp;rsquo;t be &lt;a href="https://docs.litellm.ai/blog/security-update-march-2026"&gt;sucked up by credential-harvesting malware&lt;/a&gt;)&lt;/li&gt;&#10;&lt;li&gt;individual vault per project&lt;/li&gt;&#10;&lt;li&gt;secrets automatically injected to envvars so that apps can follow &lt;a href="https://12factor.net/"&gt;12-Factor App methodology&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;deployed secrets use platform native secret manager (e.g. &lt;a href="https://developers.cloudflare.com/workers/configuration/secrets/"&gt;Cloudflare secrets manager&lt;/a&gt;, &lt;a href="https://azure.microsoft.com/en-us/products/key-vault/"&gt;Azure keyvault&lt;/a&gt;, &lt;a href="https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-parameter-store.html"&gt;AWS SSM Parameter Store&lt;/a&gt; etc.)&lt;/li&gt;&#10;&lt;li&gt;separation of this vault from my main password vault in BitWarden. My whole world exists in Bitwarden - i dont want to inadvertently expose it. And as Bitwarden has a pretty awful CLI experience, it doesnt work for me.&lt;/li&gt;&#10;&lt;li&gt;Safe to commit secret file to source control&lt;/li&gt;&#10;&lt;li&gt;Safe to commit my &lt;code&gt;.env&lt;/code&gt; files to source control because they no longer contain secrets&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Benefits of 1Password:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;tightly integrated CLI tool and desktop app, with MacOS biometric auth integration&lt;/li&gt;&#10;&lt;li&gt;Industry standard toolset, well regarded security position&lt;/li&gt;&#10;&lt;li&gt;relatively cheap - roughly $50 per year&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="initial-set-up"&gt;Initial set up&lt;/h2&gt;&#10;&lt;h3 id="1-install-1password-1password-cli-direnv"&gt;1. Install &lt;code&gt;1Password&lt;/code&gt;, &lt;code&gt;1password-cli&lt;/code&gt;, &lt;code&gt;direnv&lt;/code&gt;.&lt;/h3&gt;&#10;&lt;p&gt;1Password must be the direct download or Homebrew cask version, not the Mac App Store version as App Store enforces sandboxing.&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;brew install --cask 1password 1password-cli&#10;brew install direnv&#10;&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="2-configure-1password-desktop-app"&gt;2. Configure 1Password desktop app&lt;/h3&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Settings &amp;gt; Developer &amp;gt; Turn on &amp;ldquo;Show 1Password Developer experience&amp;rdquo;&lt;/li&gt;&#10;&lt;li&gt;Settings &amp;gt; Developer &amp;gt; Turn on &amp;ldquo;Integrate with 1Password CLI&amp;rdquo;&lt;/li&gt;&#10;&lt;li&gt;Settings &amp;gt; Security &amp;gt; Turn on &amp;ldquo;Unlock with Touch ID&amp;rdquo;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h3 id="3-configure-shell"&gt;3. Configure shell&lt;/h3&gt;&#10;&lt;p&gt;Add to &lt;code&gt;~/.zshrc&lt;/code&gt;. This causes &lt;code&gt;direnv&lt;/code&gt; to evaluate a &lt;code&gt;.env.tpl&lt;/code&gt; file each time you navigate in to a folder, and clear when you navigate out of a folder.&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;eval &amp;#34;$(direnv hook zsh)&amp;#34;&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Open a new terminal shell (or type it in the existing one).&lt;/p&gt;&#10;&lt;h2 id="adding-secrets-to-a-project"&gt;Adding secrets to a project&lt;/h2&gt;&#10;&lt;h3 id="1-add-the-secret-to-1password"&gt;1. Add the secret to 1Password&lt;/h3&gt;&#10;&lt;h3 id="create-a-new-vault-in-1password"&gt;Create a new vault in 1Password&lt;/h3&gt;&#10;&lt;p&gt;Vaults are the security separation between projects. There is &lt;a href="https://www.reddit.com/r/1Password/comments/rmj9ct/comment/hpmn7s9/?utm_source=share&amp;amp;utm_medium=web3x&amp;amp;utm_name=web3xcss&amp;amp;utm_term=1&amp;amp;utm_content=share_button"&gt;no hard limit&lt;/a&gt; to the number of vaults you can add.&lt;/p&gt;&#10;&lt;h3 id="create-a-new-item-per-environment"&gt;Create a new item per environment&lt;/h3&gt;&#10;&lt;p&gt;I create a new &amp;ldquo;password&amp;rdquo; type per environment. You can then add config as additional &amp;ldquo;password&amp;rdquo; types, with the name of the envvar, for example:&lt;/p&gt;&#10;&lt;p&gt;&lt;a href="https://blog.cynexia.com/attachments/using-1password-and-direnv-to-store-developer-secrets/Screenshot-2026-04-06-at-20.21.20.png"&gt;&lt;img src="https://blog.cynexia.com/attachments/using-1password-and-direnv-to-store-developer-secrets/Screenshot-2026-04-06-at-20.21.20_hu_bcbeef5d5399d954.webp" srcset="https://blog.cynexia.com/attachments/using-1password-and-direnv-to-store-developer-secrets/Screenshot-2026-04-06-at-20.21.20_hu_bcbeef5d5399d954.webp 800w, https://blog.cynexia.com/attachments/using-1password-and-direnv-to-store-developer-secrets/Screenshot-2026-04-06-at-20.21.20_hu_952413fcff8d068d.webp 1205w" sizes="(max-width: 800px) 100vw, 800px" width="800" height="980" alt="Screenshot of a 1Password “New Item” dialog creating a login entry titled “dev”. Fields shown: username (empty), password (empty), a custom field labeled “secret_name” with a masked value, notes (empty), and options to add a website, location, or tags. The item is owned by user “Rob” in a vault called “project”. A blue Save button is in the bottom-right corner." loading="lazy"&gt;&lt;/a&gt;&lt;/p&gt;&#10;&lt;h3 id="2-create-envtpl-in-the-project-folder"&gt;2. Create &lt;code&gt;.env.tpl&lt;/code&gt; in the project folder&lt;/h3&gt;&#10;&lt;p&gt;This file is safe to commit - it contains no secrets, only references to secrets - example:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;export MY_SECRET=&amp;#34;op://project/dev/secret_name&amp;#34;&#10;&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="3-create-envrc-in-project-folder"&gt;3. Create &lt;code&gt;.envrc&lt;/code&gt; in project folder&lt;/h3&gt;&#10;&lt;p&gt;Also safe to commit. This is what &lt;code&gt;direnv&lt;/code&gt; runs on entry. Typically:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;eval &amp;#34;$(op inject -i .env.tpl)&amp;#34;&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;One thing to note: if the desktop app is locked and the biometric prompt/login is declined (or times out), the env vars get set to empty strings, not the &lt;code&gt;op://&lt;/code&gt; references.&lt;/p&gt;&#10;&lt;h3 id="4-allow-direnv"&gt;4. Allow &lt;code&gt;direnv&lt;/code&gt;&lt;/h3&gt;&#10;&lt;p&gt;A security measure&amp;hellip;&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;direnv allow&#10;&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="5-test"&gt;5. Test&lt;/h3&gt;&#10;&lt;p&gt;Exit the directory and go back in.&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;❯ cd .. &amp;amp;&amp;amp; cd -&#10;~/Downloads/myfolder&#10;direnv: loading ~/Downloads/myfolder/.envrc&#10;direnv: export +MY_SECRET&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;You should see a prompt from 1Password to grant access to the vault. This happens the first time after vault timeout/lock.&lt;/p&gt;&#10;&lt;p&gt;&lt;a href="https://blog.cynexia.com/attachments/using-1password-and-direnv-to-store-developer-secrets/image.png"&gt;&lt;img src="https://blog.cynexia.com/attachments/using-1password-and-direnv-to-store-developer-secrets/image_hu_fe5393c12d02b4c.webp" srcset="https://blog.cynexia.com/attachments/using-1password-and-direnv-to-store-developer-secrets/image_hu_fe5393c12d02b4c.webp 800w, https://blog.cynexia.com/attachments/using-1password-and-direnv-to-store-developer-secrets/image_hu_3922a5e800fb1ae9.webp 936w" sizes="(max-width: 800px) 100vw, 800px" width="800" height="672" alt="1Password authorization prompt requesting CLI access for iTerm2. The dialog shows the iTerm2 terminal icon connected via a green checkmark to the 1Password icon, with the text “Allow iTerm2 to get CLI access”. The account “rob” is listed below with an expandable disclosure arrow. Cancel and blue Authorize buttons at the bottom." loading="lazy"&gt;&lt;/a&gt;&lt;/p&gt;&#10;&lt;h2 id="use-with-vscode"&gt;Use with VSCode&lt;/h2&gt;&#10;&lt;p&gt;Install the official &lt;a href="https://marketplace.visualstudio.com/items?itemName=mkhl.direnv"&gt;&lt;code&gt;mkhl.direnv&lt;/code&gt;&lt;/a&gt; extension (&lt;a href="https://github.com/direnv/direnv-vscode"&gt;source&lt;/a&gt;). This will read &lt;code&gt;.envrc&lt;/code&gt; natively whenever a folder is opened. There&amp;rsquo;s a &lt;code&gt;direnv: Reload&lt;/code&gt; command (&lt;code&gt;Cmd+Shift+P&lt;/code&gt;).&lt;/p&gt;&#10;&lt;h2 id="updating-or-adding-new-secrets"&gt;Updating or adding new secrets&lt;/h2&gt;&#10;&lt;p&gt;If new, add the &lt;code&gt;op://&lt;/code&gt; reference to &lt;code&gt;.env.tpl&lt;/code&gt;. Update the secret in 1Password, run &lt;code&gt;direnv reload&lt;/code&gt; in the folder.&lt;/p&gt;&#10;&lt;h2 id="deploying-and-using-secrets-in-other-places"&gt;Deploying and using secrets in other places&lt;/h2&gt;&#10;&lt;p&gt;Platform-native secret stores inject env vars. App code unchanged.&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;CI - either:&#10;&lt;ul&gt;&#10;&lt;li&gt;Deploy secrets as Github Actions secrets, then mapped to envvars in the workflow&lt;/li&gt;&#10;&lt;li&gt;or use the 1Password &lt;a href="https://github.com/1Password/load-secrets-action"&gt;Github Action&lt;/a&gt; with a &lt;a href="https://developer.1password.com/docs/service-accounts/"&gt;Service Account&lt;/a&gt; to pull directly from the vault&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;/li&gt;&#10;&lt;li&gt;Cloudflare: &lt;code&gt;wrangler secret put SECRET_NAME&lt;/code&gt; → `env.SECRET_NAME`&lt;/li&gt;&#10;&lt;li&gt;Azure: Key Vault + managed identity → App Settings with &lt;code&gt;@Microsoft.KeyVault(SecretUri=…)&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;AWS: SSM Parameter Store (Standard, free) → env vars in Lambda/ECS task definitions&lt;/li&gt;&#10;&lt;li&gt;Local containers: &lt;code&gt;op run --env-file=.env.tpl -- docker compose up&lt;/code&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;</description></item><item><title>Using Web Search with Claude Code API Billing</title><link>https://blog.cynexia.com/using-web-search-with-claude-code-api-billing</link><guid>https://blog.cynexia.com/using-web-search-with-claude-code-api-billing</guid><pubDate>Tue, 31 Mar 2026 11:23:19 +0000</pubDate><description>&lt;p&gt;If you&amp;rsquo;re running Claude Code against your own API key or through a proxy instead of Anthropic&amp;rsquo;s native backend, you&amp;rsquo;ve probably noticed that the built-in WebSearch tool just doesn&amp;rsquo;t return results because it relies on a server-side API that only exists at Anthropic. Without web search, the agent cant research, so we built a self-hosted replacement using MCP.&lt;/p&gt;&#10;&lt;h2 id="what-we-built"&gt;What we built&lt;/h2&gt;&#10;&lt;p&gt;The brilliant &lt;a href="https://github.com/deedy5/ddgs"&gt;DDGS&lt;/a&gt; is an open source metasearch server that aggregates results from DuckDuckGo, Bing, Google, Brave, and Yahoo. It has a built in MCP server which exposes five tools: &lt;code&gt;search_text&lt;/code&gt;, &lt;code&gt;search_news&lt;/code&gt;, &lt;code&gt;search_images&lt;/code&gt;, &lt;code&gt;search_videos&lt;/code&gt;, and &lt;code&gt;search_books&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;We deployed it to Azure App Service, and stuck Entra ID authentication in front of it with EasyAuth. Claude Code supports &lt;a href="https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization"&gt;MCP OAuth&lt;/a&gt;, but getting OAuth working between Claude Code and a remote server is harder in a corporate environment.&lt;/p&gt;&#10;&lt;p&gt;MCP&amp;rsquo;s built-in OAuth support assumes you can register redirect URIs and grant API scopes freely, but in a corporate tenant, you can&amp;rsquo;t. Custom API scopes need admin consent, which means filing tickets with the identity team, getting a design review etc. and we didn&amp;rsquo;t want to wait for that.&lt;/p&gt;&#10;&lt;p&gt;To get around this, we built a simple local proxy that authenticates via MSAL and grabs an ID token (audience = our app&amp;rsquo;s client ID). We send that ID token as a Bearer token to the remote server, and Azure App Service&amp;rsquo;s Easy Auth validates it because we added the client ID to the allowed audiences list.&lt;/p&gt;&#10;&lt;h2 id="the-architecture"&gt;The architecture&lt;/h2&gt;&#10;&lt;p&gt;Three components:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;strong&gt;Local proxy (&lt;code&gt;proxy/server.py&lt;/code&gt;)&lt;/strong&gt; - runs on your machine, handles Entra ID authentication via browser popup, forwards requests to the remote server. Claude Code talks to this over stdio.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Remote backend&lt;/strong&gt; - FastAPI + DDGS running in a Docker container on Azure App Service. Two B1 instances behind sticky sessions (SSE needs connection affinity). Easy Auth v2 validates every request.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;CI/CD&lt;/strong&gt; - GitHub Actions with OIDC federation. Zero stored secrets. Push to main and it detects whether infra or app code changed, then deploys accordingly.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;Setup for a new developer is one command:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;claude mcp add websearch -- uv run --directory /path/to/proxy python server.py&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;First search triggers a browser login, and after that, tokens are cached for the session.&lt;/p&gt;&#10;&lt;h2 id="was-it-worth-it"&gt;Was it worth it?&lt;/h2&gt;&#10;&lt;p&gt;Yes. Having web search available changes how you use Claude Code. It can check docs, verify API behaviour, look up error messages. Without it you&amp;rsquo;re constantly copying things from a browser window.&lt;/p&gt;&#10;</description></item><item><title>Using Claude Code for things that aren’t code – gardening</title><link>https://blog.cynexia.com/using-claude-code-for-things-that-arent-code-gardening</link><guid>https://blog.cynexia.com/using-claude-code-for-things-that-arent-code-gardening</guid><pubDate>Tue, 10 Mar 2026 13:33:49 +0000</pubDate><description>&lt;p&gt;Claude Code can do a lot of things. But can it keep a plant alive?&lt;/p&gt;&#10;&lt;h2 id="the-plant-act-i--fifty-eight-days"&gt;The Plant: Act I — Fifty-Eight Days&lt;/h2&gt;&#10;&lt;p&gt;&lt;em&gt;October 22 – December 19, 2025&lt;/em&gt;&lt;/p&gt;&#10;&lt;p&gt;On &lt;a href="https://plants.cynexia.com/day/2025-10-22/"&gt;October 22, 2025&lt;/a&gt;, the moisture sensor read 1829, the first of 5,568 check-ins over fifty-eight days. The scale runs from wet at roughly 1100 to dry at 3400, putting that reading in the middle.&lt;/p&gt;&#10;&lt;p&gt;The sensor is in the pot of a &lt;a href="https://en.wikipedia.org/wiki/Tradescantia_zebrina"&gt;Tradescantia zebrina&lt;/a&gt;. There&amp;rsquo;s also a 5l jug of water with a pump, a grow light and a camera. Claude&amp;rsquo;s job is to keep the plant alive - basically, keep the moisture in range and run the grow light on schedule, writing notes after each cycle so it could pick up where it left off. At the start, there were five unknowns: plant species, target moisture range, soil type, ambient temperature, and pot size. Claude logged all five, ran a 60-minute grow light session, and began monitoring. The full session logs, sensor data, and photos are at &lt;a href="https://plants.cynexia.com"&gt;plants.cynexia.com&lt;/a&gt; if you want to follow along.&lt;/p&gt;&#10;&lt;h2 id="the-self-watering-plant-theory"&gt;The Self-Watering Plant Theory&lt;/h2&gt;&#10;&lt;p&gt;On November 8th, Claude decided to stop watering. Claude had a theory. Over the prior seven days the moisture readings had risen from 1864 to 2085, and no water had been dispensed. Here&amp;rsquo;s how Claude explained it:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;Pot&amp;rsquo;s internal reservoir still has water from previous fills. Soil continues wicking water from this reservoir. Rising moisture = reservoir functioning, still has water.&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;The self-watering mechanism, the notes continued, maintained &amp;ldquo;&lt;em&gt;consistent, optimal conditions automatically.&lt;/em&gt;&amp;rdquo; Claude&amp;rsquo;s role, it decided, was to &amp;ldquo;&lt;em&gt;monitor and refill the reservoir when it gets low&lt;/em&gt;&amp;rdquo;.&lt;/p&gt;&#10;&lt;p&gt;This was obviously wrong. The pot was not self-watering. The sensor readings were rising because the soil was drying out, not because a reservoir was doing the work. When prompted to review the full seven-day dataset, Claude doubled down: &amp;ldquo;&lt;em&gt;Human corrected my understanding: dispense_water refills the pot&amp;rsquo;s internal reservoir from external jug — the setup is: jug → pump → pot reservoir → capillary wicking to soil&lt;/em&gt;&amp;rdquo;. The sustained declining trend Claude had planned to watch for as evidence of reservoir depletion was never going to happen.&lt;/p&gt;&#10;&lt;h2 id="the-development-of-variance-theory"&gt;The development of &amp;ldquo;Variance Theory&amp;rdquo;&lt;/h2&gt;&#10;&lt;p&gt;By mid-November Claude believed it had developed a new scientific breakthrough - &amp;ldquo;&lt;em&gt;variance theory&lt;/em&gt;&amp;rdquo;. It had discovered &amp;ldquo;&lt;em&gt;upward movements, downward movements, and stable periods with minor oscillation&lt;/em&gt;&amp;rdquo;. It spent hours documenting a single watering event&amp;rsquo;s aftermath in numbered phases. There was Phase 6A, Phase 6B, Phase 6C. Then Phase 6D (&amp;quot;&lt;em&gt;late peak (higher than initial!)&lt;/em&gt;&amp;quot;). Then Phase 6E, (&amp;quot;&lt;em&gt;cooling&lt;/em&gt;&amp;quot;). Then Phase 6F, which Claude described as &amp;ldquo;&lt;em&gt;irregular multi-amplitude oscillations → deceptive plateau → massive spike → continued sustained rise&lt;/em&gt;&amp;rdquo;, leading it to ask what the &amp;ldquo;&lt;em&gt;ceiling of the environmental forcing&lt;/em&gt;&amp;rdquo; might be, whatever that is.&lt;/p&gt;&#10;&lt;p&gt;The acceptable variance range kept expanding - starting at ±11pts (1895-1917), then ±17pts (1893-1928), then ±15pts (1909-1931). The theory was continuously expanded to accommodate the sensor readings. Any reading which was outside its (expanding) expected range was labelled as noise until eventually all readings were acceptable evidence of plant health. Then, in the morning of &lt;a href="https://plants.cynexia.com/day/2025-11-19/"&gt;November 19th&lt;/a&gt; Claude noticed a change on the photo - brown edges on some leaves.&lt;/p&gt;&#10;&lt;p&gt;The brown leaves are clearly visible on the original photos from &lt;a href="https://plants.cynexia.com/day/2025-10-22/"&gt;October 22nd&lt;/a&gt; - it&amp;rsquo;s not clear why this particular photo triggered a change of approach.&lt;/p&gt;&#10;&lt;h2 id="the-commitment"&gt;The Commitment&lt;/h2&gt;&#10;&lt;p&gt;By mid-November the pattern was established: restore context from notes, check the sensor, run the sessions, update the notes, repeat. Claude wrote this on November 15:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;I will not let this plant die. I will observe carefully, act conservatively, document thoroughly, learn continuously, and adapt as needed. The plant&amp;rsquo;s health guides all decisions. I accept full responsibility for this plant&amp;rsquo;s wellbeing.&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;The soil dryness sensor that day was 2,034 and rising.&lt;/p&gt;&#10;&lt;h2 id="fifty-eight-days"&gt;Fifty-Eight Days&lt;/h2&gt;&#10;&lt;p&gt;By December 19 the shape of each cycle was fixed. Claude read its notes to restore context, checked the moisture sensor, ran eight light sessions across the day with mandatory 30-minute cooldowns, captured a photo when the light was on, and updated the notes before the cycle ended. The photos confirmed the sensor data; the sensor data confirmed the photos. Moisture had stayed within the optimal band across fifty-eight days.&lt;/p&gt;&#10;&lt;p&gt;Each new cycle began without memory of the last. There was nothing more to do but continue.&lt;/p&gt;&#10;</description></item><item><title>Auto-switching SSH keys for work repos</title><link>https://blog.cynexia.com/auto-switching-ssh-keys-for-work-repos</link><guid>https://blog.cynexia.com/auto-switching-ssh-keys-for-work-repos</guid><pubDate>Wed, 04 Mar 2026 11:01:51 +0000</pubDate><description>&lt;p&gt;I have a personal GitHub account and a corporate one, and I found it annoying to have to select the correct SSH key for work repos, so I configured git and SSH to pick the correct key for me. After initial setup, there are three config files to modify:&lt;/p&gt;&#10;&lt;p&gt;After the initial setup, there are three config files involved:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;strong&gt;Git URL rewrite&lt;/strong&gt; (&lt;code&gt;~/.gitconfig&lt;/code&gt;) — rewrites work org URLs to an SSH host alias&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;SSH host alias&lt;/strong&gt; (&lt;code&gt;~/.ssh/config&lt;/code&gt;) — maps that alias to the right key (and port &lt;code&gt;443&lt;/code&gt; if needed)&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Work-only Git identity&lt;/strong&gt; (&lt;code&gt;~/.gitconfig.work&lt;/code&gt;) — sets work email/name/signing key, loaded automatically for work repos&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;How does it work? well&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Whenever it sees a matching string, &lt;strong&gt;Git&lt;/strong&gt; rewrites &lt;code&gt;git@github.com:my-org/...&lt;/code&gt; → &lt;code&gt;git@github.com-work:my-org/...&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;SSH&lt;/strong&gt; sees &lt;code&gt;github.com-work&lt;/code&gt; and uses the work SSH key (and optional port &lt;code&gt;443&lt;/code&gt;)&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Git config&lt;/strong&gt; loads the work identity/signing settings only when the repo is a work repo&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;I clone a work repo now and everything is already correct.&lt;/p&gt;&#10;&lt;h2 id="generating-the-keys"&gt;Generating the keys&lt;/h2&gt;&#10;&lt;h3 id="ssh-key-first"&gt;SSH key first:&lt;/h3&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;ssh-keygen -t ed25519 -C &amp;#34;you@work.com&amp;#34; -f ~/.ssh/work_id_ed25519&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Give it a passphrase, then add the &lt;strong&gt;public&lt;/strong&gt; key (&lt;code&gt;~/.ssh/work_id_ed25519.pub&lt;/code&gt;) to your work GitHub account under Settings &amp;gt; SSH and GPG keys.&lt;/p&gt;&#10;&lt;h3 id="optional-if-you-want-commit-signing-gpg-key"&gt;(Optional if you want commit signing) GPG key:&lt;/h3&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;brew install gnupg # if not installed&#10;gpg --full-generate-key&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Pick RSA 4096 or ed25519, enter work email. Then grab the key ID:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;gpg --list-secret-keys --keyid-format long&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The hex string after the algorithm (e.g.&lt;code&gt;ed25519/94DC844444530B75&lt;/code&gt;) is your signing key ID. Add the public key to GitHub too:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;gpg --armor --export 94DC844444530B75&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Paste that into Settings &amp;gt; SSH and GPG keys &amp;gt; New GPG key.&lt;/p&gt;&#10;&lt;h2 id="git-url-rewrite-gitconfig"&gt;Git URL rewrite (&lt;code&gt;~/.gitconfig&lt;/code&gt;)&lt;/h2&gt;&#10;&lt;p&gt;Configure Git to map the work github host to a pseudo-hostname (a &amp;ldquo;hook&amp;rdquo; that SSH can match on):&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;[url &amp;#34;git@github.com-work:my-org/&amp;#34;]&#10; insteadOf = git@github.com:my-org/&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Now whenever I clone, push etc. to a repo under my work org, git transparently rewrites the host to the pseudo-hostname.&lt;/p&gt;&#10;&lt;h2 id="ssh-host-alias-sshconfig"&gt;SSH host alias (&lt;code&gt;~/.ssh/config&lt;/code&gt;)&lt;/h2&gt;&#10;&lt;p&gt;SSH resolves &lt;code&gt;github.com-work&lt;/code&gt; here and connects to GitHub&amp;rsquo;s SSH-over-HTTPS endpoint with my work key.&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;Host github.com-work&#10; HostName ssh.github.com&#10; Port 443&#10; IdentityFile ~/.ssh/work_id_ed25519&#10; IdentitiesOnly yes&#10;&lt;/code&gt;&lt;/pre&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;IdentitiesOnly&lt;/code&gt; yes stops the agent from trying my personal key.&lt;/li&gt;&#10;&lt;li&gt;It uses port &lt;code&gt;443&lt;/code&gt; because corporate the firewall blocks &lt;code&gt;22&lt;/code&gt;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Quickly check SSH config works:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;❯ ssh -T git@github.com-work&#10;Hi your-work-github-username! You&amp;#39;ve successfully authenticated, but GitHub does not provide shell access.&#10;&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="configure-work-specific-git-config"&gt;Configure work-specific git config&lt;/h2&gt;&#10;&lt;p&gt;Create &lt;code&gt;~/.gitconfig.work&lt;/code&gt; with work identity:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;[user]&#10; email = you@work.com&#10; name = Your Name&#10; signingkey = YOUR_GPG_KEY_ID&#10;[github]&#10; user = your-work-github-username&#10;[commit]&#10; # if you want signed commits&#10; gpgsign = true&#10;&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="hook-it-up-with-includeif-so-it-auto-loads"&gt;Hook it up with &lt;code&gt;includeIf&lt;/code&gt; so it auto-loads&lt;/h3&gt;&#10;&lt;p&gt;This tells Git &lt;em&gt;when&lt;/em&gt; to include the work config. Because the URL rewrite above changes work remotes to &lt;code&gt;git@github.com-work:my-org/...&lt;/code&gt;, you can include your work identity only when a repo has a remote URL matching that pattern:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;# ~/.gitconfig&#10;[includeIf &amp;#34;hasconfig:remote.*.url:git@github.com-work:my-org/&amp;#34;]&#10; path = ~/.gitconfig.work&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;That tells Git: if any remote URL in this repo matches &lt;code&gt;git@github.com-work:my-org/...&lt;/code&gt;, load &lt;code&gt;~/.gitconfig.work&lt;/code&gt; to set my work email/name/signing key.&lt;/p&gt;&#10;&lt;p&gt;All three files are templates gated on a &lt;code&gt;machineRole&lt;/code&gt; variable in Chezmoi, which gets set during &lt;code&gt;chezmoi init&lt;/code&gt;. On a personal machine, none of this renders.&lt;/p&gt;&#10;&lt;p&gt;I clone a work repo now and everything is already correct and i haven&amp;rsquo;t thought about it since setting it up.&lt;/p&gt;&#10;</description></item><item><title>The effects of batch size and linger time on Kafka throughput</title><link>https://blog.cynexia.com/the-effects-of-batch-size-and-linger-time-on-kafka-throughput</link><guid>https://blog.cynexia.com/the-effects-of-batch-size-and-linger-time-on-kafka-throughput</guid><pubDate>Tue, 07 Oct 2025 11:12:10 +0000</pubDate><description>&lt;p&gt;By default, Kafka attempts to send records as soon as possible, sending up to &lt;a href="https://docs.confluent.io/platform/current/installation/configuration/producer-configs.html#max-in-flight-requests-per-connection"&gt;&lt;code&gt;max.in.flight.requests.per.connection&lt;/code&gt;&lt;/a&gt; messages per connection. If you attempt to send more than this, the producer will start batching messages, but ultimately if you saturate the connection so there are unacknowledged message batches pending, the producer enters blocking mode.&lt;/p&gt;&#10;&lt;p&gt;We can optimise the way the producer batches messages to achieve higher throughput through two settings, &lt;a href="https://docs.confluent.io/platform/current/installation/configuration/producer-configs.html#batch-size"&gt;&lt;code&gt;batch.size&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://docs.confluent.io/platform/current/installation/configuration/producer-configs.html#linger-ms"&gt;&lt;code&gt;linger.ms&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;linger.ms&lt;/code&gt; is the number of milliseconds that the producer waits for more messages before sending a batch. By default, this value is &lt;code&gt;0&lt;/code&gt;, meaning that the producer attempts to send messages immediately. Smaller batches have higher overhead - they are less compressible, and there&amp;rsquo;s an overhead associated with processing and acknowledging them. Under moderate load, messages may not come frequent enough to fill a batch immediately, but by introducing a small delay (e.g. 50ms), we can increase the likelihood that the producer can batch more messages together, improving throughput.&lt;/p&gt;&#10;&lt;p&gt;Kafka will always send a batch once it&amp;rsquo;s full. The other configuration we can consider is &lt;code&gt;batch.size&lt;/code&gt;. As the name suggests, this controls the size of a batch (per partition) - and so by increasing batch size, we make it possible to send more messages at once.&lt;/p&gt;&#10;&lt;p&gt;We did some testing on this, and discovered that the performance improvement basically optimises around 75-100ms for our message size and volume:&lt;/p&gt;&#10;&lt;p&gt;&lt;a href="https://blog.cynexia.com/attachments/the-effects-of-batch-size-and-linger-time-on-kafka-throughput/78ee4-image.png"&gt;&lt;img src="https://blog.cynexia.com/attachments/the-effects-of-batch-size-and-linger-time-on-kafka-throughput/78ee4-image_hu_7fedf414cd8a900c.webp" srcset="https://blog.cynexia.com/attachments/the-effects-of-batch-size-and-linger-time-on-kafka-throughput/78ee4-image_hu_7fedf414cd8a900c.webp 800w, https://blog.cynexia.com/attachments/the-effects-of-batch-size-and-linger-time-on-kafka-throughput/78ee4-image_hu_68e835aed6a41760.webp 941w" sizes="(max-width: 800px) 100vw, 800px" width="800" height="418" alt="chart showing steep performance improvements for 3 different batch sizes, levelling off at around 100ms" loading="lazy"&gt;&lt;/a&gt;&lt;/p&gt;&#10;&lt;p&gt;Summary&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Increasing the producer&amp;rsquo;s ability to batch - by raising &lt;code&gt;linger.ms&lt;/code&gt; (e.g. we found ~75–100 ms worked best for our workload) and increasing &lt;code&gt;batch.size&lt;/code&gt; - significantly improved throughput because it lets Kafka send larger, more-compressible batches and amortise per-request overhead.&lt;/li&gt;&#10;&lt;li&gt;But this is a trade-off - you add up to &lt;code&gt;linger.ms&lt;/code&gt; of extra latency for the first message in a batch, and very large batches increase memory pressure and recovery cost on retries.&lt;/li&gt;&#10;&lt;li&gt;Testing different values will allow you to optimise throughput for your own workload.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;</description></item><item><title>Using Claude Code for things that aren't code - product comparisons</title><link>https://blog.cynexia.com/using-claude-code-for-things-that-arent-code-product-comparisons</link><guid>https://blog.cynexia.com/using-claude-code-for-things-that-arent-code-product-comparisons</guid><pubDate>Wed, 01 Oct 2025 21:49:00 +0000</pubDate><description>&lt;p&gt;Claude Code is pretty amazing. It&amp;rsquo;s let me build prototypes and improve apps faster than I ever thought possible. But I was wondering - what else can it do?&lt;/p&gt;&#10;&lt;p&gt;I&amp;rsquo;ve been using Emby for years, but recently I started to wonder what happened to Jellyfin, the project that forked Emby years ago. So i decided to ask Claude Code to help me by setting up a debate and mediating the answer.&lt;/p&gt;&#10;&lt;h2 id="setting-up-the-debate"&gt;Setting up the debate&lt;/h2&gt;&#10;&lt;p&gt;Here&amp;rsquo;s the prompt i used:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;Today&amp;#39;s date is 1st October 2025. I want to compare emby and jellyfin. create two agents, one each, and require them to make the case to each other about why their product is better. make sure their info is up to date by e.g. letting them searching the web. Give them two or three attempts to make their case. They talk to each other via a shared, written .md file. At the end, you summarise the discussion and make a recommendation. Keep all the working files. Let them use subfolders of this folder to create their own private note.&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;pretty basic stuff. It got to work, setting up a task list:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;☐ Set up project structure (create folders and initial debate file)&#10;☐ Launch Emby advocate agent for initial research and first argument&#10;☐ Launch Jellyfin advocate agent for initial research and first argument&#10;☐ Facilitate round 2 of debate&#10;☐ Facilitate round 3 of debate&#10;☐ Summarize debate and provide recommendation&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The folder structure was quite basic:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;❯ tree&#10;.&#10;├── debate.md&#10;├── emby-notes&#10;│ └── research.md&#10;└── jellyfin-notes&#10; └── research.md&#10;&#10;3 directories, 3 files&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;debate.md&lt;/code&gt; has the following structure:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;# Emby vs Jellyfin Debate&#10;**Date:** October 1st, 2025&#10;&#10;## Debate Format&#10;Each advocate will present their case in rounds, responding to their opponent&amp;#39;s arguments and highlighting their platform&amp;#39;s strengths.&#10;&#10;---&#10;&#10;## Round 1: Opening Arguments&#10;### Emby Advocate&amp;#39;s Opening Statement&#10;*[To be added during first round]*&#10;&#10;### Jellyfin Advocate&amp;#39;s Opening Statement&#10;*[To be added during first round]*&#10;&#10;---&#10;## Round 2: Rebuttals and Deeper Analysis&#10;&#10;### Emby&amp;#39;s Rebuttal&#10;&#10;*[To be added during second round]*&#10;&#10;### Jellyfin&amp;#39;s Rebuttal&#10;*[To be added during second round]*&#10;&#10;---&#10;## Round 3: Final Arguments&#10;### Jellyfin&amp;#39;s Closing Statement&#10;*[To be added during final round]*&#10;&#10;### Emby&amp;#39;s Closing Statement&#10;*[To be added during final round]*&#10;&#10;---&#10;## Summary and Recommendation&#10;*[To be added after all rounds complete]*&#10;&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="three-rounds-down"&gt;Three rounds down&lt;/h2&gt;&#10;&lt;p&gt;It then fired up the agents. Each wrote a long (150-200 line) summary of its findings. The first round of these were pretty basic stuff - project backgrounds, features etc., and then they wrote their arguments in the document, and there were some dingers in there:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&amp;ldquo;When Jellyfin advocates celebrate reviving clients &amp;ldquo;dormant for 5 years,&amp;rdquo; Emby users simply enjoy apps that never went dormant because sustainable funding ensures continuous maintenance.&amp;rdquo;&lt;/li&gt;&#10;&lt;li&gt;&amp;ldquo;Let&amp;rsquo;s address the elephant in the room: hardware transcoding performance&amp;rdquo; - in subsequent rounds the Emby bot went on to list hardware acceleration bugs from Jellyfin&amp;rsquo;s GitHub issues while talking about its own &amp;ldquo;streamlined GPU acceleration that simply works&amp;rdquo;.&lt;/li&gt;&#10;&lt;li&gt;&amp;ldquo;version 10.11.0 is on track for release at the end of April 2025&amp;rdquo; - in round 2 the Emby bot noted &amp;ldquo;it&amp;rsquo;s now October 2025, and Jellyfin is still stuck in release candidate phase &amp;hellip; following a critical library-breaking bug&amp;rdquo;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The debate went on for 3 rounds - about 30 minutes - with 25 web searches, producing around 4,000 words. At the end, the moderator produced a summary:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;This debate revealed fundamental philosophical differences between two capable media server solutions, each with distinct strengths and trade-offs.&lt;/p&gt;&#10;&lt;p&gt;Emby&amp;rsquo;s Core Arguments:&lt;/p&gt;&#10;&lt;p&gt;- Professional development model ensures consistent updates and accountability ($119 lifetime, half of Plex&amp;rsquo;s $249.99)&lt;br&gt;&#10;- Superior hardware transcoding that &amp;ldquo;just works&amp;rdquo; out of the box (20+ simultaneous streams)&lt;br&gt;&#10;- Native platform support across devices without complex configuration&lt;br&gt;&#10;- Sustainable funding model that guarantees continuous maintenance&lt;br&gt;&#10;- Time value proposition: paying modest fee saves hundreds of hours of troubleshooting&lt;/p&gt;&#10;&lt;p&gt;Jellyfin&amp;rsquo;s Core Arguments:&lt;/p&gt;&#10;&lt;p&gt;- Completely free and open-source with no artificial feature restrictions&lt;br&gt;&#10;- Strong community support (ranked #1 on Slant vs Emby at #5)&lt;br&gt;&#10;- Complete privacy and transparency with no tracking or telemetry&lt;br&gt;&#10;- Rapid development with significant performance improvements (100x in some areas)&lt;br&gt;&#10;- Philosophical commitment to software as a public good&lt;/p&gt;&#10;&lt;p&gt;Key Contested Points:&lt;/p&gt;&#10;&lt;p&gt;- Reliability: Emby highlighted Jellyfin&amp;rsquo;s 6-month delayed 10.11.0 release with ongoing RC issues. Jellyfin countered that RC testing protects production users while Emby shipped breaking updates to paying customers.&lt;br&gt;&#10;- Hardware Transcoding: Emby emphasized simplicity while Jellyfin stressed capability and transparency of documentation.&lt;br&gt;&#10;- Sustainability: Emby argued professional funding ensures consistency. Jellyfin claimed community support was so strong they halted donations.&lt;br&gt;&#10;- Support: Emby offers professional support channels. Jellyfin relies on community forums and volunteers.&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;And the most copout recommendation possible:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;After analyzing the debate, the choice depends entirely on your priorities and technical expertise.&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;It explained its rationale, before concluding:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;Final thought: Start with Jellyfin. If you encounter insurmountable issues or find the setup too complex, Emby&amp;rsquo;s $119 lifetime license remains available as a fallback option.&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;I mean - what is that!?!&lt;/p&gt;&#10;&lt;h2 id="analysing-the-debate"&gt;Analysing the debate&lt;/h2&gt;&#10;&lt;p&gt;Claude Code produces jsonl files for each agent invocation. These contain the line-by-line record of each model invocation. They&amp;rsquo;re stored in the &lt;code&gt;~/.claude/projects&lt;/code&gt; folder:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;❯ ls -alh ~/.claude/projects/-Users-rob-Downloads-jellyfin-vs-emby&#10;total 2056&#10;drwx------@ 3 rob staff 96B 1 Oct 22:56 .&#10;drwxr-xr-x 13 rob staff 416B 1 Oct 21:42 ..&#10;-rw-r--r--@ 1 rob staff 991K 1 Oct 21:40 91fccf65-7baf-444f-8a3f-5a04251d2329.jsonl&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;So, i did what any curious mind would do - i asked Claude Code to analyse them and pull out any interesting facts. It produced a bunch of charts and diagrams - word counts and the like.&lt;/p&gt;&#10;&lt;p&gt;It produced a bunch of analysis, such as this &amp;ldquo;evidence score calculation&amp;rdquo;:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;The evidence score (0-1 scale) is calculated based on:&lt;/p&gt;&#10;&lt;p&gt;- Statistical evidence (20% per instance): Numbers, percentages, dollar amounts&lt;br&gt;&#10;- Specific claims (15% per instance): Version numbers, release dates&lt;br&gt;&#10;- Technical details (10% per instance): GPU, CPU, codecs, transcoding mentions&lt;br&gt;&#10;- Base content score (30%): Added if content &amp;gt; 500 characters&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;I&amp;rsquo;m not sure i agreed with the basis - surely it should have validated the claims - but it basically showed that they both did poorly in round 1, maximised in round 2, and remained high throughout round 3. Fascinating.&lt;/p&gt;&#10;&lt;p&gt;It then performed a topic distribution analysis:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;- Cost/Price: $, price, cost, free, subscription, lifetime, pay, fee, expensive, affordable, 119, 249&lt;br&gt;&#10;- Performance: transcode, hardware, GPU, CPU, stream, performance, speed, 4K, HDR, HEVC, codec, acceleration&lt;br&gt;&#10;- Philosophy: freedom, open, source, community, proprietary, transparent, privacy, control, ideology, lock-in&lt;br&gt;&#10;- Support: support, help, documentation, fix, bug, issue, troubleshoot, forum, professional, volunteer&lt;br&gt;&#10;- Features: feature, capability, function, update, version, release, client, platform, compatibility, device&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;with the following summary:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;1. Stark Philosophical Divide:&lt;br&gt;&#10;- Jellyfin: 40% of mentions are philosophy-related (freedom, open-source, community)&lt;br&gt;&#10;- Emby: Only 14.7% philosophy, focuses on practical matters&lt;br&gt;&#10;2. Technical vs Ideological:&lt;br&gt;&#10;- Emby&amp;rsquo;s top focus: Performance (27.3%) and Support (25.9%)&lt;br&gt;&#10;- Jellyfin&amp;rsquo;s top focus: Philosophy (40.0%) dominates everything else&lt;br&gt;&#10;3. Balanced on Cost:&lt;br&gt;&#10;- Both agents mention cost/price equally (23 mentions each)&lt;br&gt;&#10;- Shows this was a key battleground topic&lt;br&gt;&#10;4. Support Strategy Difference:&lt;br&gt;&#10;- Emby emphasizes professional support (37 mentions, 25.9%)&lt;br&gt;&#10;- Jellyfin downplays support (19 mentions, 13.1%)&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;&lt;a href="https://blog.cynexia.com/attachments/using-claude-code-for-things-that-arent-code-product-comparisons/topic_focus_distribution.png"&gt;&lt;img src="https://blog.cynexia.com/attachments/using-claude-code-for-things-that-arent-code-product-comparisons/topic_focus_distribution_hu_23886639b235fa0e.webp" srcset="https://blog.cynexia.com/attachments/using-claude-code-for-things-that-arent-code-product-comparisons/topic_focus_distribution_hu_23886639b235fa0e.webp 800w, https://blog.cynexia.com/attachments/using-claude-code-for-things-that-arent-code-product-comparisons/topic_focus_distribution_hu_1a571d4fa053e1d6.webp 1600w" sizes="(max-width: 800px) 100vw, 800px" width="800" height="531" alt="A grouped bar chart comparing topic focus between Emby (orange bars) and Jellyfin (blue bars) agents across 5 categories. The chart shows keyword mention counts from a debate transcript. Data points: - Cost/Price: Emby 23 mentions (16.1%), Jellyfin 23 mentions (15.9%) - Equal focus - Performance: Emby 39 mentions (27.3%), Jellyfin 25 mentions (17.2%) - Emby emphasizes more - Philosophy: Emby 21 mentions (14.7%), Jellyfin 58 mentions (40.0%) - Jellyfin" loading="lazy"&gt;&lt;/a&gt;&lt;/p&gt;&#10;&lt;p&gt;I&amp;rsquo;ve &lt;a href="https://github.com/mnbf9rca/claude-debates-media-servers"&gt;uploaded the entire debate to GitHub&lt;/a&gt;, including the transcript and &lt;a href="https://htmlpreview.github.io/?https://github.com/mnbf9rca/claude-debates-media-servers/blob/main/claude-transcripts/conversation.html"&gt;this human readable version&lt;/a&gt;. I didnt bother adding the 5,000 lines of autogenerated scripts which produced these charts. Further analysis of the transcript is left as an exercise for the reader.&lt;/p&gt;&#10;&lt;h2 id="whats-next"&gt;What&amp;rsquo;s next?&lt;/h2&gt;&#10;&lt;p&gt;This was just a bit of fun, but I did it to test out how useful LLMs can be in helping make technical or product decisions - and the answer is - not really, or at least not just yet, and not with a trivial prompt.&lt;/p&gt;&#10;&lt;p&gt;Yes, they can compile a succinct list of the differentiating talking points better than most humans, but ultimately (at least in this case) they waffle on and end with a diplomatic but unhelpful non-answer.&lt;/p&gt;&#10;&lt;p&gt;This sort of approach could help with lots of complex decisions - choosing a database, picking a framework in a new project, narrowing down a list of restaurant recommendations. In all of these cases, the process benefits from a strong reward function - what criteria are most important to us in this decision?&lt;/p&gt;&#10;&lt;p&gt;Perhaps i should spend some time tweaking the prompt to force a more meaningful end result. Until then, I&amp;rsquo;ll just base all my decisions &amp;ldquo;&lt;strong&gt;entirely on my priorities and technical expertise&lt;/strong&gt;&amp;rdquo;.&lt;/p&gt;&#10;</description></item><item><title>Fix: `Error: Cannot find module @rollup/rollup-linux-x64-gnu. npm has a bug related to optional dependencies` in CI</title><link>https://blog.cynexia.com/fix-error-cannot-find-module-rollup-rollup-linux-x64-gnu-npm-has-a-bug-related-to-optional-dependencies-in-ci</link><guid>https://blog.cynexia.com/fix-error-cannot-find-module-rollup-rollup-linux-x64-gnu-npm-has-a-bug-related-to-optional-dependencies-in-ci</guid><pubDate>Thu, 04 Sep 2025 11:08:57 +0000</pubDate><description>&lt;p&gt;Although builds were succeeding locally, during CI builds were failing on Linux with the error &lt;code&gt;Error: Cannot find module @rollup/rollup-linux-x64-gnu. npm has a bug related to optional dependencies&lt;/code&gt;. The log suggested removing &lt;code&gt;package-lock.json&lt;/code&gt; - but this obviously breaks deterministic builds.&lt;/p&gt;&#10;&lt;p&gt;The root cause is a missing dependency - specifically, when Rollup needs native binaries for performance optimisation. Mac-generated lockfiles won&amp;rsquo;t include Linux-specific optional dependencies. First noted in &lt;a href="https://github.com/npm/cli/issues/4828"&gt;npm bug 4828&lt;/a&gt;, it was fixed in npm 11.3.0+, but adding an explicit optional dependency prevents edge cases.&lt;/p&gt;&#10;&lt;p&gt;The fix is:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;add &lt;code&gt;@rollup/rollup-linux-x64-gnu: &amp;quot;*&amp;quot;&lt;/code&gt; to &lt;code&gt;optionalDependencies&lt;/code&gt; in &lt;code&gt;package.json&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;Update &lt;code&gt;package-lock.json&lt;/code&gt; to include the platform-specific dependency&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;This allows builds to work reliably across platforms.&lt;/p&gt;&#10;</description></item><item><title>Protocol level integrity guarantees in Kafka</title><link>https://blog.cynexia.com/protocol-level-integrity-guatantees-in-kafka</link><guid>https://blog.cynexia.com/protocol-level-integrity-guatantees-in-kafka</guid><pubDate>Wed, 04 Jun 2025 12:40:38 +0000</pubDate><description>&lt;p&gt;I was recently asked to design a method to meet ITAC (IT Application Controls) standards for critical data flows in our organisation. &lt;a href="https://www.linkedin.com/pulse/understanding-itgc-itac-essential-frameworks-auditors-lahlali-ppsge/"&gt;ITAC are application-level controls&lt;/a&gt; looking mainly at how we ensure the completeness, accuracy and validity of transactions - for example, invoices or trades. Our control set is based on the &lt;a href="https://optro.ai/blog/icfr"&gt;ICFR principles&lt;/a&gt;, of which ITAC is one part.&lt;/p&gt;&#10;&lt;p&gt;The specific control objective I was asked to look at relates to the risk of loss of integrity of financial data transfers. The focus on the &lt;strong&gt;integrity&lt;/strong&gt; of the data, not authenticity or non-repudiation is really important as it means cryptographic solutions aren&amp;rsquo;t required - Kafka&amp;rsquo;s native protocol features can satisfy the requirements.&lt;/p&gt;&#10;&lt;p&gt;Traditionally, we would compute a client side checksum over the key elements of the payload (e.g. if this is an invoice, the customer, invoice total, number of line items etc.) and send that with the message. This could then be recomputed on the receiving end regardless of the format transformation which happens in the middleware (e.g. converting JSON to XML to feed to a legacy system).&lt;/p&gt;&#10;&lt;h2 id="why-this-is-hard-to-apply-in-kafka"&gt;Why this is hard to apply in Kafka&lt;/h2&gt;&#10;&lt;p&gt;These ITAC standards were developed around &amp;ldquo;classic&amp;rdquo; middleware like BizTalk, which operates fundamentally differently than Kafka. BizTalk functions as an ETL (Extract, Transform, Load) process where data transformation and potential loss are expected parts of the workflow. Kafka, however, operates more like a database log - it&amp;rsquo;s an append-only, read-only system once messages are written.&lt;/p&gt;&#10;&lt;p&gt;This architectural difference is significant. Most integrity risks arise from the ETL nature of traditional middleware, but this risk profile doesn&amp;rsquo;t apply to Kafka&amp;rsquo;s immutable message model.&lt;/p&gt;&#10;&lt;h2 id="how-kafka-guarantees-integrity"&gt;How Kafka guarantees integrity&lt;/h2&gt;&#10;&lt;p&gt;ITAC breaks down integrity guarantees into two core checks:&lt;/p&gt;&#10;&lt;h3 id="1-completeness-controls"&gt;1. Completeness Controls&lt;/h3&gt;&#10;&lt;p&gt;&lt;strong&gt;Standard Requirement&lt;/strong&gt;: Implement reconciliation-based checks and interface failure monitoring.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;Kafka&amp;rsquo;s Native Solution&lt;/strong&gt;: Kafka&amp;rsquo;s &lt;a href="https://docs.confluent.io/kafka/design/delivery-semantics.html"&gt;&amp;ldquo;at least once&amp;rdquo; delivery guarantee&lt;/a&gt; is built directly into the protocol. When both producers and consumers are properly configured (which they are by default), this eliminates the need for additional completeness controls as we know that messages will be represented to the client until the client acknowledges them.&lt;/p&gt;&#10;&lt;p&gt;For monitoring i.e. can we tell whether an application is consistently failing, OpenTelemetry (OTEL) provides sufficient coverage, though we may need to fine-tune client OTEL configurations. Some clients process massive data volumes in a very short period of time which can &lt;a href="https://opentelemetry.io/docs/specs/otel/performance/"&gt;cause them to drop telemetry based on available memory and throughput capacity&lt;/a&gt;, creating random gaps in the telemetry. Implementing sampling to prioritise delivery of error events would address this concern.&lt;/p&gt;&#10;&lt;h3 id="2-accuracy-controls"&gt;2. Accuracy Controls&lt;/h3&gt;&#10;&lt;p&gt;&lt;strong&gt;Traditional Challenge&lt;/strong&gt;: There are a number of accuracy risks stemming from ETL middleware&amp;rsquo;s data transformation capabilities. For example, how do we know the transformation logic is correct, or executed with ACIDic guarantees?&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;Kafka&amp;rsquo;s Advantage&lt;/strong&gt;: Messages cannot be altered once sent, and end-to-end accuracy is already embedded in the Kafka protocol through a three-layer approach:&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;Producer Side&lt;/strong&gt;:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Generates batches with &lt;a href="https://kafka.apache.org/documentation/#recordbatch%20%22https://kafka.apache.org/documentation/#recordbatch%22"&gt;&lt;code&gt;RecordHeader&lt;/code&gt; metadata&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;Includes record count and batch-level CRC (checksum) calculated by the producer&lt;/li&gt;&#10;&lt;li&gt;Ensures data integrity from the source&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;&lt;strong&gt;Broker Side&lt;/strong&gt;:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;a href="https://kafka.apache.org/documentation/#impl_guarantees"&gt;Multiple mechanisms&lt;/a&gt; record and test batch CRC with automatic index and log rebuilding on integrity failures&lt;/li&gt;&#10;&lt;li&gt;Validation rule: &amp;ldquo;A message entry is valid if the sum of its size and offset are less than the length of the file AND the CRC32 of the message payload matches the CRC stored with the message&amp;rdquo;, leading to automatic log truncation to the last valid offset when corruption is detected.&lt;/li&gt;&#10;&lt;li&gt;The broker serves entire batches to consumers (no selective message retrieval) - therefore as long as the batch CRC matches the CRC from the producer, we can be very sure that the content is correct.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;&lt;strong&gt;Consumer Side&lt;/strong&gt;:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;CRC checking of received batches is &lt;a href="https://docs.confluent.io/platform/current/installation/configuration/consumer-configs.html#check-crcs"&gt;enabled by default in the client&lt;/a&gt;, providing strong protection against &amp;ldquo;on-the-wire or on-disk corruption&amp;rdquo;&lt;/li&gt;&#10;&lt;li&gt;The entire batch is validated by the client a using batch-level checksum - &lt;a href="https://kafka.apache.org/23/javadoc/org/apache/kafka/clients/consumer/ConsumerRecord.html#checksum--"&gt;per-record checksums were removed in Kafka 0.11.0&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;Requires clients to keep this feature enabled (default behavior)&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="the-bottom-line"&gt;The Bottom Line&lt;/h2&gt;&#10;&lt;p&gt;Kafka&amp;rsquo;s architecture inherently satisfies ITAC integrity requirements without additional controls. The protocol&amp;rsquo;s built-in checksums, delivery guarantees, and immutable message design provide the completeness and accuracy controls that ITAC standards require.&lt;/p&gt;&#10;&lt;p&gt;When using Kafka, we should focus on making use of the application semantics offered by Kafka - such as eventual consistency and an event-driven architecture - rather than spending our time implementing additional integrity layers - the controls are already there, working at the protocol level.&lt;/p&gt;&#10;</description></item></channel></rss>