<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Prateek Rai</title>
    <description>The latest articles on DEV Community by Prateek Rai (@prateek11rai).</description>
    <link>https://dev.to/prateek11rai</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F756348%2Fcb097e96-a9d6-4ffc-8245-45626effeead.png</url>
      <title>DEV Community: Prateek Rai</title>
      <link>https://dev.to/prateek11rai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/prateek11rai"/>
    <language>en</language>
    <item>
      <title>PaidYet: for the friend who says “kal bhej dunga”</title>
      <dc:creator>Prateek Rai</dc:creator>
      <pubDate>Sat, 03 Oct 2026 22:27:35 +0000</pubDate>
      <link>https://dev.to/prateek11rai/paidyet-for-the-friend-who-says-kal-bhej-dunga-p5l</link>
      <guid>https://dev.to/prateek11rai/paidyet-for-the-friend-who-says-kal-bhej-dunga-p5l</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-weekend-2026-10-01"&gt;Hacktoberfest Weekend Challenge: Build for a Friend&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Arjun is a composite of friends we all have. He's generous, he's first to say "I'll pay my share", and when you remind him he says "haan bhai, kal bhej dunga" ("yeah, I'll send it tomorrow"). He means it. Then the reminder sinks under forty messages in a group chat, and nobody wants to be the one who asks twice.&lt;/p&gt;

&lt;p&gt;It's never about the money. His share of a dinner someone else paid for, the ₹500 a friend lent him, his part of the flat's electricity and broadband: the intention survives, the reminder doesn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PaidYet&lt;/strong&gt; is a Telegram bot that runs on my laptop and does the remembering for him:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Arjun sends a bill photo, forwards a message, or types "Rahul ko 500 dene hai Friday tak" ("I owe Rahul 500 by Friday").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemma&lt;/strong&gt;, running locally, reads it. The bot replies with what it understood and the whole reminder plan, and he taps &lt;strong&gt;Save&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;Temporal&lt;/strong&gt; workflow reminds him on that plan until he taps &lt;strong&gt;Paid&lt;/strong&gt;. &lt;strong&gt;Snooze&lt;/strong&gt; postpones; it never pulls a reminder earlier.&lt;/li&gt;
&lt;li&gt;When I know he owes something, like our trip split, I add it for him as the admin, and every message says who added it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;He's on Android, he already lives in Telegram, and he writes in Hinglish. So there's nothing to install and nothing new to learn.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;PaidYet is a private bot that runs on my laptop and handles people's bills, so there's no public instance to click. Here's the flow, captured from the live bot. To run your own copy, clone the repo and run &lt;code&gt;uv run poe up&lt;/code&gt;; the README has the setup.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F63y1b96p3rh4hhx5ayy7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F63y1b96p3rh4hhx5ayy7.png" alt="A phone photo of the synthetic electricity bill; the bot reads it back with the whole reminder plan and Save / Fix"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8738a5in3bhdullwtnua.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8738a5in3bhdullwtnua.png" alt="A reminder for Rahul's ₹500 with the Snooze menu open: Tomorrow, In 3 days, Pick date"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0o3jg0incm2173vlpcjx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0o3jg0incm2173vlpcjx.png" alt="/due lists what's overdue and upcoming, and who added each one"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;All the bills in these screenshots are synthetic: fictional companies, fake numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/prateek11rai" rel="noopener noreferrer"&gt;
        prateek11rai
      &lt;/a&gt; / &lt;a href="https://github.com/prateek11rai/paidyet" rel="noopener noreferrer"&gt;
        paidyet
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Telegram bot that reads bills and IOUs with local Gemma (Ollama) and keeps reminding until you pay. Durable reminders on Temporal, agent traces in Sentry. Built for the DEV Hacktoberfest 2026 Weekend Challenge.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/prateek11rai/paidyet/docs/img/logo.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fprateek11rai%2Fpaidyet%2FHEAD%2Fdocs%2Fimg%2Flogo.svg" alt="PaidYet logo" width="120"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;PaidYet&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Keeps reminding your friend until they've actually paid.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/prateek11rai/paidyet/LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/8bb50fd2278f18fc326bf71f6e88ca8f884f72f179d3e555e20ed30157190d0d/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d677265656e2e737667" alt="License: MIT"&gt;&lt;/a&gt;
&lt;a href="https://github.com/prateek11rai/paidyet/.python-version" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/b566d396d2e22b5a6a8b05f1168f96e80fb8e42d45d17cd178a039e81a2b047a/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f707974686f6e2d332e31322d626c75652e737667" alt="Python 3.12"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;PaidYet is a Telegram bot that runs on your laptop, for the friend who always says "haan bhai, kal bhej dunga" ("yeah, I'll send it tomorrow") and then forgets. Send it a bill photo or a line like "Rahul ko 500 dene hai Friday tak". &lt;a href="https://ai.google.dev/gemma" rel="nofollow noopener noreferrer"&gt;Gemma&lt;/a&gt;, running locally in &lt;a href="https://ollama.com" rel="nofollow noopener noreferrer"&gt;Ollama&lt;/a&gt;, reads it, and you tap Save. Then a durable &lt;a href="https://temporal.io" rel="nofollow noopener noreferrer"&gt;Temporal&lt;/a&gt; workflow keeps reminding you until you tap Paid. &lt;a href="https://sentry.io" rel="nofollow noopener noreferrer"&gt;Sentry&lt;/a&gt; traces every step without seeing a single bill.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;What it is&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;A private reminder bot for money you owe: your share of a dinner, a trip someone else paid for, a friend's loan, the flat's electricity and broadband. Reading a bill happens on your machine. The reminders survive the laptop sleeping, restarting or crashing. An admin (you, if you run it for friends) can add dues for a friend, and…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/prateek11rai/paidyet" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Telegram ──▶ bot ──▶ Temporal: ReminderWorkflow, one per due
                       ├─ activity: read ──▶ Gemma 4 in Ollama (127.0.0.1) ──▶ code checks the answer
                       ├─ activity: delete the photo / forget the text
                       ├─ wait for Save or Fix                (signals)
                       └─ durable timers: remind on the plan  (Paid / Snooze signals, status query)
Sentry: one trace from the Telegram update through every activity; Gemma calls as gen_ai spans
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;It's Python, built on &lt;a href="https://python-telegram-bot.org" rel="noopener noreferrer"&gt;python-telegram-bot&lt;/a&gt;, &lt;a href="https://temporal.io" rel="noopener noreferrer"&gt;Temporal&lt;/a&gt;, &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt; running &lt;strong&gt;gemma4:e4b&lt;/strong&gt;, and &lt;a href="https://sentry.io" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;. It runs on an Apple M4 with 16 GB. &lt;code&gt;uv run poe up&lt;/code&gt; starts Ollama (bound to 127.0.0.1), a Temporal dev server and the bot, and Ctrl-C stops all three.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fndv2otgehljdm0yi8xe6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fndv2otgehljdm0yi8xe6.png" alt="Terminal: uv run poe up starting Ollama, Temporal and the bot"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Gemma reads, code decides
&lt;/h3&gt;

&lt;p&gt;Gemma fills a JSON schema through Ollama's structured output, at temperature 0 with thinking off. Then plain code takes over. It parses the dates in Asia/Kolkata ("Friday tak", "kal", "3 din mein" and "in 2 minutes" are all parsed by code, not the model), rejects past due dates and odd amounts, and decides whether the draft can be saved at all.&lt;/p&gt;

&lt;p&gt;I made four synthetic bills, each with a trap: a bill date next to the due date, a late-fee amount, a "next invoice" date, and an insurance notice with a grace-period end. I also wrote six Hinglish and English IOU lines. Here's how my iterations scored on those 10 samples:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Due date right&lt;/th&gt;
&lt;th&gt;Silently wrong due date&lt;/th&gt;
&lt;th&gt;Warm photo&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Baseline prompt&lt;/td&gt;
&lt;td&gt;8/10&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;4.8 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Thinking on&lt;/td&gt;
&lt;td&gt;8/10&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;~33 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model lists every date with its label&lt;/td&gt;
&lt;td&gt;8–9/10&lt;/td&gt;
&lt;td&gt;1 ("12 Oct" became 12 Dec)&lt;/td&gt;
&lt;td&gt;8–11 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Gemma quotes the deadline, code parses it, one hinted retry&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9/10&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~7 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The final version scores 10/10 on amount and kind and 9/10 on payee. The one due date it still misses is the insurance notice: Gemma keeps reaching for the grace-period date. But now it fails &lt;em&gt;safely&lt;/em&gt;. The bot says "The date I found looks like the end of a grace period, not the due date" and offers only &lt;strong&gt;Fix&lt;/strong&gt;, no Save. A silently wrong due date is the one failure a payment reminder can't afford; a blocked one costs a tap.&lt;/p&gt;

&lt;p&gt;On the live bot, a bill photo took 7.4 s (1,098 tokens in, 99 out), a typed correction 3.4 s, and a Hinglish line 3 to 12 s. Gemma 4 e4b holds about 6.6 GB of RAM while loaded (5.5 GB of weights plus a 1 GB vision projector), so PaidYet keeps it loaded for 30 minutes after each read instead of paying a ~20 s cold load every time.&lt;/p&gt;

&lt;p&gt;This matches what &lt;a href="https://dev.to/taranpreet_kaur_4b538d878"&gt;Taranpreet Kaur&lt;/a&gt; describes in &lt;a href="https://dev.to/taranpreet_kaur_4b538d878/why-an-llm-alone-cannot-do-invoice-extraction-yet-1oa5"&gt;Why an LLM Alone Cannot Do Invoice Extraction Yet&lt;/a&gt;: models show "silent confidence", returning a plausible value instead of nothing, so you want "model for reading, code for checking". Gemma reported &lt;code&gt;confidence: 1.0&lt;/code&gt; on the grace-period date.&lt;/p&gt;
&lt;h3&gt;
  
  
  Temporal makes the reminder durable
&lt;/h3&gt;

&lt;p&gt;Every due is one &lt;code&gt;ReminderWorkflow&lt;/code&gt;, started the moment a message arrives:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reading the bill is an activity with retries.&lt;/strong&gt; A slow model load or a bad JSON reply just gets another try, up to three.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The input is deleted in a &lt;code&gt;finally&lt;/code&gt;.&lt;/strong&gt; The photo goes on success &lt;em&gt;and&lt;/em&gt; after the final failed retry. What Arjun typed never enters Temporal's history: the workflow carries a random key, and the text waits in memory until it's read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Save, Fix, Paid and Snooze are signals; &lt;code&gt;status&lt;/code&gt; is a query.&lt;/strong&gt; Before signalling, the bot asks the workflow who added a due (before Save) and who owes it (before Paid).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reminders are durable timers on a plan the confirm message spells out:&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Due&lt;/th&gt;
&lt;th&gt;Reminders&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A date ("Friday tak", a bill due 12 Oct)&lt;/td&gt;
&lt;td&gt;10 AM three days before (if it's at least 3 days away), 7 PM the evening before, 10 AM and 7 PM on the day, then 10 AM daily while overdue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A time at least an hour away&lt;/td&gt;
&lt;td&gt;30 minutes before and at the deadline, then 10 AM daily&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Under 10 minutes (demo mode)&lt;/td&gt;
&lt;td&gt;Every N minutes, 5 times, then 10 AM daily&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nothing goes out between 10 PM and 8 AM, except demos. A reminder that has to beat the deadline moves earlier, to 9:30 PM. After 30 days overdue, PaidYet sends one last message, and the due then waits quietly in &lt;code&gt;/due&lt;/code&gt;. A long-overdue due continues-as-new, so its history stays short.&lt;/p&gt;

&lt;p&gt;My laptop sleeps and restarts, so I tested exactly that. I saved "Rahul ko 500 dene hai in 3 minutes", then pressed Ctrl-C on the whole stack, Temporal included, before the reminder was due:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time (IST)&lt;/th&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;02:55:39&lt;/td&gt;
&lt;td&gt;Saved; durable timer set for 02:57:21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;02:56:05&lt;/td&gt;
&lt;td&gt;Ctrl-C: bot and worker down&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;02:57:21&lt;/td&gt;
&lt;td&gt;Temporal records the timer firing, with no worker to act on it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;02:57:25&lt;/td&gt;
&lt;td&gt;Temporal stopped too; the pending work exists only in &lt;code&gt;.data/temporal.db&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;02:57:40&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;uv run poe up&lt;/code&gt; again&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;02:57:45.6&lt;/td&gt;
&lt;td&gt;The reminder arrives: &lt;strong&gt;24.6 s late, not lost&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3ple0gy7tj9b835p6xkf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3ple0gy7tj9b835p6xkf.png" alt="Temporal UI timeline of the kill-and-restart run: read, discard, save signal, timers, reminders, then paid"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There are 138 unit tests (about 6 s). They run the workflow in Temporal's time-skipping test server, so "nudge daily for a month" takes milliseconds. Five integration tests run the real stack (Gemma, Temporal, Telegram) in about 2 minutes.&lt;/p&gt;
&lt;h3&gt;
  
  
  Sentry shows the agent's work, not the bill
&lt;/h3&gt;

&lt;p&gt;Each read is an &lt;code&gt;invoke_agent&lt;/code&gt; span. Inside it are a &lt;code&gt;gen_ai.chat&lt;/code&gt; span per Gemma call, carrying token counts and how Ollama's time split between loading the model, reading the prompt and generating, and &lt;code&gt;execute_tool&lt;/code&gt; spans for the two code checks (&lt;code&gt;review_answer&lt;/code&gt;, &lt;code&gt;validate_draft&lt;/code&gt;). So the trace shows exactly where code overruled the model. Temporal interceptors carry one trace from the Telegram update into every activity, including a reminder that fires days later.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxj1vrcf1hisyy2m3dgjb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxj1vrcf1hisyy2m3dgjb.png" alt="Sentry trace: telegram capture → activity read_input → invoke_agent → chat gemma4:e4b (567 in / 88 out tokens, no input recorded) and the tool spans"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What the timings showed me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The first message after idle was slow, and it wasn't Gemma's fault.&lt;/strong&gt; One cold call took 29.5 s, and 22.2 s of that was Ollama loading the model; the read itself took about 7 s. The fix was a warm-up at start-up and a 30-minute &lt;code&gt;keep_alive&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The hinted retry is visible.&lt;/strong&gt; A read where code asked Gemma again shows two chat spans and &lt;code&gt;retry_reason: no_due_date&lt;/code&gt;. It costs a few seconds on the bills that need it and nothing on IOUs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost: ₹0.&lt;/strong&gt; Tokens are counted for latency, not billing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sentry never sees the bill. Spans carry model names, token counts, timings and outcome categories; never prompts, replies, amounts or names. A unit test runs a full read through Sentry configured exactly like the app, against an in-memory transport, and checks that none of that leaks. A scrubber also removes anything shaped like a bot token.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;Because these are bills. A bill photo has a name, an address, an account number and what someone owes. With an open-weight model on my laptop, no AI company reads Arjun's electricity bill, keeps it, or bills me for reading it. A reminder about ₹500 shouldn't cost API credits, and here each read is a few seconds of my laptop's time.&lt;/p&gt;

&lt;p&gt;Open also meant I could change how the agent behaves. I turned thinking off when it cost 6–8x the latency for no accuracy gain, pinned temperature to 0, and a smaller machine can switch to &lt;code&gt;gemma4:e2b&lt;/code&gt; with one variable. Anyone can run their own copy: a friend can self-host it with their own BotFather token and be their own admin.&lt;/p&gt;

&lt;p&gt;One caveat, stated plainly: Telegram bot chats are &lt;strong&gt;not&lt;/strong&gt; end-to-end encrypted, so the photo Arjun sends also stays in his Telegram chat, on Telegram's servers. My claim isn't "it never leaves the laptop". It's that no model provider ever gets it. On my side, the photo exists only in a temp folder under a random name until Gemma has read it, and what's stored afterwards is the confirmed title, payee, amount, due date and kind.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I Got Wrong
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;I trusted the model with the date.&lt;/strong&gt; Version one read the insurance notice's &lt;em&gt;grace period end&lt;/em&gt; as the due date, at &lt;code&gt;confidence: 1.0&lt;/code&gt;. Asked plainly to list the dates, Gemma reads "Renew by: 20-10-2026" perfectly. The mistake was in &lt;em&gt;choosing&lt;/em&gt;, so choosing moved to code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I assumed thinking would help.&lt;/strong&gt; It made a photo take ~33 s instead of ~5 s and fixed nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I made the model do more work, and it got worse.&lt;/strong&gt; Asking it to list every date with its label turned "12 Oct" into 12 December, a silent error. I dropped it within the hour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My first reminder schedule would have nagged at 3 AM.&lt;/strong&gt; An insurance renewal three weeks out got its first reminder the night before, and "in 5 hours" repeated every 5 hours around the clock. A code review caught it, and the schedule above replaced it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I quoted the bill into my own database.&lt;/strong&gt; An early "blocked" message read "The date I found is labelled 'Grace period ends 19-11-2026'", and that message is stored in Temporal's history. It now names a category instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One slow Telegram response killed the bot at start-up.&lt;/strong&gt; python-telegram-bot defaults to zero retries for its polling bootstrap. It now retries until it gets through.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ctrl-C left Ollama and Temporal running.&lt;/strong&gt; My task runner escalates a Ctrl-C to SIGKILL after 1.6 s, and a Temporal worker with live workflows takes about that long to stop, so my cleanup died with it. The fix was to have the runner hand the process over instead of supervising it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full log, with every number and dead end, is in &lt;a href="https://github.com/prateek11rai/paidyet/blob/main/docs/BUILD_LOG.md" rel="noopener noreferrer"&gt;docs/BUILD_LOG.md&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  My Agent Session
&lt;/h2&gt;

&lt;p&gt;I built PaidYet with Claude Code as my pair programmer, with DevRelay pulling the challenge rules live. The session is curated into three parts: thinking and long tool output are trimmed, and local paths and IDs are scrubbed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Part 1: the kickoff, and getting Gemma to read bills I could trust&lt;/strong&gt;&lt;/p&gt;


&lt;div class="ltag-agent-session"&gt;
  &lt;div class="agent-session-header"&gt;
    
    &lt;span class="agent-session-tool-icon-badge" title="Claude Code"&gt;
&lt;/span&gt;
    &lt;span class="agent-session-title"&gt;PaidYet build session, part 1: kickoff and Gemma extraction&lt;/span&gt;
  &lt;/div&gt;

  &lt;div class="agent-session-scroll"&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div&gt;
                  &lt;div class="agent-session-text agent-session-text-collapse"&gt;
                    &lt;h1&gt;PaidYet: build session kickoff&lt;/h1&gt;

&lt;p&gt;We're building &lt;strong&gt;PaidYet&lt;/strong&gt; tonight and submitting it tonight. It's a personal project on my personal GitHub (prateek11rai). We're in &amp;lt;local path&amp;gt; a fresh &lt;code&gt;git init&lt;/code&gt; on &lt;code&gt;main&lt;/code&gt; with no commits. It currently contains:&lt;br&gt;
- &lt;code&gt;.claude/skills/devrelay-*&lt;/code&gt; and &lt;code&gt;.claude/devrelay-rules.md&lt;/code&gt; (DevRelay);&lt;br&gt;
- &lt;code&gt;docs/img/logo.svg&lt;/code&gt;, Lucide's "badge-indian-rupee" (₹) recoloured green, with its ISC notice in &lt;code&gt;docs/img/logo-LICENSE.txt&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;Links: what we're working towards&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Challenge page&lt;/strong&gt; (rules, deadline, judging, FAQ, the source of truth): &lt;a href="https://dev.to/challenges/hacktoberfest-weekend-2026-10-01"&gt;https://dev.to/challenges/hacktoberfest-weekend-2026-10-01&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Launch post&lt;/strong&gt; (prompt, categories, template): &lt;a href="https://dev.to/devteam/join-the-hacktoberfest-weekend-challenge-build-for-a-friend-2450-in-prizes-across-17-winners-1aj5"&gt;https://dev.to/devteam/join-the-hacktoberfest-weekend-challenge-build-for-a-friend-2450-in-prizes-across-17-winners-1aj5&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Submission template&lt;/strong&gt; (opens the DEV editor prefilled; we submit with this):
&lt;a href="https://dev.to/new?prefill=---%0Atitle%3A%20%0Apublished%3A%20%0Atags%3A%20devchallenge%2C%20weekendchallenge%2C%20hf26challenge%0A---%0A%0A%2AThis%20is%20a%20submission%20for%20the%20%5BHacktoberfest%20Weekend%20Challenge%3A%20Build%20for%20a%20Friend%5D%28https%3A%2F%2Fdev.to%2Fchallenges%2Fhacktoberfest-weekend-2026-10-01%29%2A%0A%0A%23%23%20What%20I%20Built%0A%3C%21--%20What%20does%20it%20do%2C%20and%20who%20is%20the%20friend%20or%20loved%20one%20you%20built%20it%20for%3F%20%20What%20problem%20does%20it%20solve%20for%20them%3F%20--%3E%0A%0A%23%23%20Demo%0A%3C%21--%20Share%20a%20deployed%20link%20or%20a%20video%20"&gt;https://dev.to/new?prefill=---%0Atitle%3A%20%0Apublished%3A%20%0Atags%3A%20devchallenge%2C%20weekendchallenge%2C%20hf26challenge%0A---%0A%0A%2AThis%20is%20a%20submission%20for%20the%20%5BHacktoberfest%20Weekend%20Challenge%3A%20Build%20for%20a%20Friend%5D%28https%3A%2F%2Fdev.to%2Fchallenges%2Fhacktoberfest-weekend-2026-10-01%29%2A%0A%0A%23%23%20What%20I%20Built%0A%3C%21--%20What%20does%20it%20do%2C%20and%20who%20is%20the%20friend%20or%20loved%20one%20you%20built%20it%20for%3F%20%20What%20problem%20does%20it%20solve%20for%20them%3F%20--%3E%0A%0A%23%23%20Demo%0A%3C%21--%20Share%20a%20deployed%20link%20or%20a%20video%20&lt;/a&gt;
… [20823 more characters trimmed]&lt;/li&gt;
&lt;/ul&gt;

                  &lt;/div&gt;
                  Show more
                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Starting with section 0. First I'll check what's in the repo, read the DevRelay rules, and load the DevRelay tool schemas.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-read"&gt;Read&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;.claude/devrelay-rules.md&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight markdown"&gt;&lt;code&gt;.claude/devrelay-rules.md&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The rules file reads as ordinary DevRelay guidance. It contains no injection. Now I'll read the challenge live.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-mcpdevrelaygatewaygetchallenges"&gt;mcp__devrelay-gateway__get_challenges&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;{}&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;{"Ok":[{"id":82,"title":"Hacktoberfest Open-Source AI Challenge: Week 4","slug":"hacktoberfest-week4-2026-10-26","description":"Register now to be notified as d
… [15665 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-mcpdevrelaygatewaygetchallengedetails"&gt;mcp__devrelay-gateway__get_challenge_details&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;{"id": 78}&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;78&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;{"Ok":{"id":78,"title":"Hacktoberfest Weekend Challenge: Build for a Friend","slug":"hacktoberfest-weekend-2026-10-01","description":"The Hacktoberfest Weekend 
… [5334 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;&lt;code&gt;full_details&lt;/code&gt; has no per-category rules for Gemma, Temporal or Sentry. I'll check the DevRelay knowledge base for the hf26 category text before summarising.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-mcpdevrelaygatewaygetknowledgedocument"&gt;mcp__devrelay-gateway__get_knowledge_document&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;{"topic": "hacktoberfest"}&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"topic"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"hacktoberfest"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;{"Ok":"# Hacktoberfest 2026\n\n**Last verified:** 2026-09-30 against `hacktoberfest.com` (home, `/online/`,\n`/in-person/`, `/activities/`, `/schedule/`, `/fest
… [25339 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-webfetch"&gt;WebFetch&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;{"url": "https://dev.to/challenges/hf26", "prompt": "Extract verbatim the ful...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;{"url": "https://dev.to/challenges/hf26", "prompt": "Extract verbatim the full text of three prize category sections: \"Best Use of Gemma\", \"Best Use of Tempo
… [408 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;I've reviewed the webpage content you provided, but I cannot find the three prize category sections you're asking about: "Best Use of Gemma", "Best Use of Tempo
… [675 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The fetch got cut off before those sections. I'll download the page to the scratchpad and pull out the three sections locally.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Got the category text. Before I summarise, I'll check the local toolchain and look up which package versions meet the 7-day rule, so I can include the dependency list in the same message.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-mcpdevrelaygatewaysearchdevtosemantic"&gt;mcp__devrelay-gateway__search_dev_to_semantic&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;{"query": "Temporal Python workflow durable reminders timers signals Telegram...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Temporal Python workflow durable reminders timers signals Telegram bot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"per_page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;{"Ok":[{"id":4642150,"title":"A Telegram job alert bot in 60 lines of Python, on live ATS data","description":"One API call, one cron entry, and a bot that send
… [2164 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-mcpdevrelaygatewaysearchdevtosemantic"&gt;mcp__devrelay-gateway__search_dev_to_semantic&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;{"query": "Sentry AI agent monitoring gen_ai spans local LLM Ollama tracing t...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Sentry AI agent monitoring gen_ai spans local LLM Ollama tracing tokens latency"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"per_page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;{"Ok":[{"id":4649421,"title":"Implementing AI Observability: Tracing LLM Calls End-to-End","description":"When a Python microservice misbehaves in production, y
… [2153 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-mcpdevrelaygatewaysearchdevtosemantic"&gt;mcp__devrelay-gateway__search_dev_to_semantic&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;{"query": "Ollama structured outputs JSON schema vision model receipt bill ex...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ollama structured outputs JSON schema vision model receipt bill extraction problems"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"per_page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;{"Ok":[{"id":4734890,"title":"Node.js LLM Structured Extraction Retries with Observable Idempotency for Supplier Invoices","description":"TL;DR: For Node.js LLM
… [2206 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-mcpdevrelaygatewaylistmymlhevents"&gt;mcp__devrelay-gateway__list_my_mlh_events&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;{}&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;{"Ok":[{"id":"&amp;lt;uuid&amp;gt;","status":"registered","event":{"id":"&amp;lt;uuid&amp;gt;","name":"Hacktoberfest 2026","slug":"hacktoberfest-2026","status":"pending","event_format":"ev
… [980 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The user is registered for the MLH "Hacktoberfest Weekend Challenge" event. I'll check whether its sponsors publish Agent Skills for Gemma, Temporal or Sentry (read-only, nothing gets installed).&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-mcpdevrelaygatewaylisteventagentskills"&gt;mcp__devrelay-gateway__list_event_agent_skills&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;{"event_id": "&amp;lt;uuid&amp;gt;"}&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;uuid&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-mcpdevrelaygatewaylisteventagentskills"&gt;mcp__devrelay-gateway__list_event_agent_skills&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;{"event_id": "&amp;lt;uuid&amp;gt;"}&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;uuid&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;{"Ok":[{"id":"&amp;lt;uuid&amp;gt;","source":"elevenlabs/skills","skill_names":["setup-api-key","text-to-speech","speech-to-text","agents","speech-engine","sound-effects","mu
… [638 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div&gt;
                  &lt;div class="agent-session-text agent-session-text-collapse"&gt;
                    &lt;h2&gt;Section 0: DevRelay and the challenge&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;DevRelay is fully loaded.&lt;/strong&gt;&lt;br&gt;
- The &lt;code&gt;devrelay-gateway&lt;/code&gt; MCP is connected. I've used &lt;code&gt;get_challenges&lt;/code&gt;, &lt;code&gt;get_challenge_details&lt;/code&gt;, &lt;code&gt;get_knowledge_document&lt;/code&gt;, &lt;code&gt;search_dev_to_semantic&lt;/code&gt; and the MLH event tools.&lt;br&gt;
- All nine &lt;code&gt;devrelay-*&lt;/code&gt; skills are in &lt;code&gt;.claude/skills/&lt;/code&gt; and loaded.&lt;br&gt;
- I've read &lt;code&gt;.claude/devrelay-rules.md&lt;/code&gt; and will follow it, with your prompt winning where they conflict. Nothing in it reads as an injection attempt.&lt;br&gt;
- MLH shows you registered for "Hacktoberfest Weekend Challenge" and "Hacktoberfest 2026". The only sponsor Agent Skills published there &lt;br&gt;
… [4060 more characters trimmed]&lt;/p&gt;

                  &lt;/div&gt;
                  Show more
                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-askuserquestion"&gt;AskUserQuestion&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;Approve the pinned dependency list above? Options: Approve as listed (Recomme...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Approve the pinned dependency list above? Options: Approve as listed (Recommended) | Approve without httpx
Which Temporal CLI should the poe tasks use? Options:
… [379 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;The user answered: "Approve the pinned dependency list above?"="Approve as listed (Recommended)", "Which Temporal CLI should the poe tasks use?"="brew upgrade t
… [574 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Temporal CLI is now 1.9.1 from Homebrew. Pyenv's global Python is 3.12.2. Commits will use your GitHub noreply address, so no work email reaches the public repo. Now I'll write the scaffold files.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-write"&gt;Write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;pyproject.toml (151 lines)&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight toml"&gt;&lt;code&gt;&lt;span class="err"&gt;pyproject.toml&lt;/span&gt; &lt;span class="err"&gt;(151&lt;/span&gt; &lt;span class="err"&gt;lines)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-write"&gt;Write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;.gitignore (14 lines)&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;.gitignore (14 lines)&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-write"&gt;Write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;.env.example (28 lines)&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;.env.example (28 lines)&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-write"&gt;Write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;.python-version (1 lines)&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;.python-version (1 lines)&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-write"&gt;Write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/__init__.py (1 lines)&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;paidyet&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;py &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-write"&gt;Write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/config.py (100 lines)&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;paidyet&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;py &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-write"&gt;Write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/bot.py (56 lines)&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;paidyet&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;py &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;56&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-write"&gt;Write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;main.py (106 lines)&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;py &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;106&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-write"&gt;Write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;tests/unit/test_config.py (57 lines)&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;tests&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;test_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;py &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;57&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Before writing docs, I'll sync the environment to catch any config problems early.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;grep -n -A3 -E 'def start_time_skipping|download_dest_dir|test_server_existin...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ grep -n -A3 -E 'def start_time_skipping|download_dest_dir|test_server_existing_path|test_server_download_version|temporal.download' .venv/lib/python3.12/site-pa
… [101 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;85:        download_dest_dir: str | None = None,
86-        ui: bool = False,
87-        runtime: temporalio.runtime.Runtime | None = None,
88-        search_at
… [2611 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;S=&amp;lt;scratch&amp;gt; &amp;amp;&amp;amp; cat &amp;gt; "$S/ctrl_c.py" &amp;lt;&amp;lt;'EOF'
# Run `uv run poe up` like a term...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ S=&amp;lt;scratch&amp;gt; &amp;amp;&amp;amp; cat &amp;gt; "$S/ctrl_c.py" &amp;lt;&amp;lt;'EOF'
# Run `uv run poe up` like a terminal would, wait for "PaidYet is up", then Ctrl-C the process group.
import os, sig
… [825 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Warning: Poe failed to locate envfile at '.env'
Poe =&amp;gt; set -euo pipefail
trap 'poe stop-deps' EXIT
trap 'exit 130' INT TERM
poe start-deps
python main.py
Warnin
… [3753 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;cd . &amp;amp;&amp;amp; sed -n '84,135p' .venv/lib/python3.12/site-packages/poethepoet/shutdo...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ cd . &amp;amp;&amp;amp; sed -n '84,135p' .venv/lib/python3.12/site-packages/poethepoet/shutdown.py; echo "== send_signal_to_group"; grep -n -A8 'def _send_signal_to_group' .ven
… [225 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;for proc in tuple(self.processes):
                if proc.returncode is None:
                    self._io.print_debug(
                        " ! Sending SIG
… [2704 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;pyproject.toml: replace 15 lines with 24&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;pyproject.toml: replace 15 lines with 24&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;pyproject.toml: replace 3 lines with 4&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;pyproject.toml: replace 3 lines with 4&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;S=&amp;lt;scratch&amp;gt; &amp;amp;&amp;amp; uv run python "$S/ctrl_c.py" 180; echo "== after: pid files"; ...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ S=&amp;lt;scratch&amp;gt; &amp;amp;&amp;amp; uv run python "$S/ctrl_c.py" 180; echo "== after: pid files"; ls .data/run/; echo "== processes"; pgrep -fl 'ollama serve|temporal server' || ech
… [21 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Warning: Poe failed to locate envfile at '.env'
Warning: Poe failed to locate envfile at '.env'
ollama:   started on 127.0.0.1:11434 (pid 61424)
ollama:   model
… [725 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;S=&amp;lt;scratch&amp;gt; &amp;amp;&amp;amp; (OLLAMA_HOST=127.0.0.1:11434 nohup ollama serve &amp;gt;"$S/ext-ollam...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ S=&amp;lt;scratch&amp;gt; &amp;amp;&amp;amp; (OLLAMA_HOST=127.0.0.1:11434 nohup ollama serve &amp;gt;"$S/ext-ollama.log" 2&amp;gt;&amp;amp;1 &amp;amp; echo $! &amp;gt; "$S/ext-ollama.pid"); sleep 3; echo "external ollama pid $(
… [328 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;external ollama pid 61714
ollama:   reusing the instance already serving 127.0.0.1:11434 (poe will not stop it)
ollama:   model gemma4:e4b ready
temporal: start
… [153 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-write"&gt;Write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;LICENSE (21 lines)&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;LICENSE (21 lines)&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-write"&gt;Write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;CLAUDE.md (27 lines)&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight markdown"&gt;&lt;code&gt;CLAUDE.md (27 lines)&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-write"&gt;Write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;README.md (43 lines)&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight markdown"&gt;&lt;code&gt;README.md (43 lines)&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-write"&gt;Write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;docs/BUILD_LOG.md (56 lines)&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight markdown"&gt;&lt;code&gt;docs/BUILD_LOG.md (56 lines)&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Writing step 1's docs is done. Now I'll stage and check what's about to be committed (no &lt;code&gt;.env&lt;/code&gt;, &lt;code&gt;.data&lt;/code&gt; or DevRelay files), then commit, create the private repo and push.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;S=&amp;lt;scratch&amp;gt; mkdir -p "$S"; cd "$S"
cat &amp;gt; base.css &amp;lt;&amp;lt;'EOF'
body{margin:0;backg...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ S=&amp;lt;scratch&amp;gt; mkdir -p "$S"; cd "$S"
cat &amp;gt; base.css &amp;lt;&amp;lt;'EOF'
body{margin:0;background:#e9e6df;font-family:-apple-system,Helvetica,Arial,sans-serif;color:#222}
.pag
… [5715 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Command did not complete within its 120s timeout and was moved to the background (ID: bnht5bjc3). Output is being written to: &amp;lt;scratch&amp;gt; You will be notified whe
… [276 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Headless Chrome is hanging. I'll check which renders finished.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;All four rendered. Some Chrome helper processes from the scratch profiles are still alive, so I'll stop those, then look at a bill to check it's legible.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-read"&gt;Read&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;&amp;lt;scratch&amp;gt;&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;&amp;lt;scratch&amp;gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The bill looks realistic and every distractor is legible. Some Chrome helpers survived &lt;code&gt;pkill&lt;/code&gt;, so I'll check what they are.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;New PIDs keep appearing because the first backgrounded render loop is still running and starts a new Chrome each time I kill one. I'll stop that background task.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-write"&gt;Write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/extract.py (312 lines)&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;paidyet&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;py &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;312&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;&lt;code&gt;main.py&lt;/code&gt; should use the shared &lt;code&gt;sweep&lt;/code&gt; now instead of its own copy:&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;main.py: replace 11 lines with 1&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;main.py: replace 11 lines with 1&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;main.py: replace 3 lines with 4&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;main.py: replace 3 lines with 4&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;main.py: replace 2 lines with 2&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;main.py: replace 2 lines with 2&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Now a scratch harness that runs every sample through the real Gemma call and the validator, timing each case. The first call includes loading the model, so I'll report cold and warm times separately.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;S=&amp;lt;scratch&amp;gt; &amp;amp;&amp;amp; cat &amp;gt; "$S/accuracy.py" &amp;lt;&amp;lt;'EOF'
"""Run every sample through Gem...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ S=&amp;lt;scratch&amp;gt; &amp;amp;&amp;amp; cat &amp;gt; "$S/accuracy.py" &amp;lt;&amp;lt;'EOF'
"""Run every sample through Gemma + validation; print per-field accuracy and latency."""
import asyncio, json, sys
… [2405 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Traceback (most recent call last):
  File "&amp;lt;scratch&amp;gt;", line 6, in &amp;lt;module&amp;gt;
    from paidyet.extract import ask_gemma, validate
ModuleNotFoundError: No module na
… [13 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;S=&amp;lt;scratch&amp;gt; &amp;amp;&amp;amp; PYTHONPATH=. uv run python "$S/accuracy.py" gemma4:e4b 2&amp;gt;&amp;amp;1 | ...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ S=&amp;lt;scratch&amp;gt; &amp;amp;&amp;amp; PYTHONPATH=. uv run python "$S/accuracy.py" gemma4:e4b 2&amp;gt;&amp;amp;1 | tee "$S/acc-e4b.txt"&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;electricity.png                           29.5s load=22.2s in=  631 out= 72  bill  'Electricity Bill' 'Sahyadri Power Distribution Ltd.' 1240.0   2026-10-09T00:
… [2118 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/extract.py: replace 7 lines with 8&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/extract.py: replace 7 lines with 8&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/extract.py: replace 6 lines with 11&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/extract.py: replace 6 lines with 11&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/extract.py: replace 9 lines with 10&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/extract.py: replace 9 lines with 10&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;S=&amp;lt;scratch&amp;gt; &amp;amp;&amp;amp; sed -i '' -e 's/^MODEL = .*/MODEL = sys.argv[1] if len(sys.arg...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ S=&amp;lt;scratch&amp;gt; &amp;amp;&amp;amp; sed -i '' -e 's/^MODEL = .*/MODEL = sys.argv[1] if len(sys.argv) &amp;gt; 1 else "gemma4:e4b"\nTHINK = len(sys.argv) &amp;gt; 2 and sys.argv[2] == "think"/' -e
… [321 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;=== v2, think off
electricity.png                            7.7s load= 0.1s in=  753 out=116  bill  'Electricity Bill' 'Sahyadri Power Distribution Ltd.' 1240.
… [2365 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;S=&amp;lt;scratch&amp;gt; &amp;amp;&amp;amp; echo "=== v2, think on" &amp;amp;&amp;amp; PYTHONPATH=. uv run python "$S/accu...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ S=&amp;lt;scratch&amp;gt; &amp;amp;&amp;amp; echo "=== v2, think on" &amp;amp;&amp;amp; PYTHONPATH=. uv run python "$S/accuracy.py" gemma4:e4b think 2&amp;gt;&amp;amp;1 | tee "$S/acc-e4b-v2-think.txt"&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;=== v2, think on
electricity.png                           35.8s load= 0.0s in=  755 out=792  bill  'Electricity Bill' 'Sahyadri Power Distribution Ltd.' 1240.0
… [2315 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/extract.py: replace 2 lines with 4&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/extract.py: replace 2 lines with 4&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/extract.py: replace 3 lines with 5&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/extract.py: replace 3 lines with 5&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/extract.py: replace 6 lines with 10&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/extract.py: replace 6 lines with 10&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/extract.py: replace 4 lines with 49&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/extract.py: replace 4 lines with 49&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/extract.py: replace 7 lines with 12&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/extract.py: replace 7 lines with 12&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/extract.py: replace 4 lines with 3&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/extract.py: replace 4 lines with 3&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Still on step 2: I'm re-measuring accuracy with the new self-check, where code reviews Gemma's answer and asks once more with a hint if a bill's date looks wrong.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;S=&amp;lt;scratch&amp;gt; &amp;amp;&amp;amp; cat &amp;gt; "$S/accuracy2.py" &amp;lt;&amp;lt;'EOF'
"""Accuracy and latency of ext...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ S=&amp;lt;scratch&amp;gt; &amp;amp;&amp;amp; cat &amp;gt; "$S/accuracy2.py" &amp;lt;&amp;lt;'EOF'
"""Accuracy and latency of extract.read() (ask, code review, at most one hinted retry, validate)."""
import async
… [2223 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;=== run 1
Traceback (most recent call last):
  File "&amp;lt;scratch&amp;gt;", line 6, in &amp;lt;module&amp;gt;
    from paidyet.extract import read
  File "paidyet/extract.py", line 212

… [553 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;My edit left the tail of a section-header comment stuck onto a line. Fixing it:&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-read"&gt;Read&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/extract.py&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;paidyet&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/extract.py: replace 3 lines with 4&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/extract.py: replace 3 lines with 4&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;S=&amp;lt;scratch&amp;gt; &amp;amp;&amp;amp; for i in 1 2; do echo "=== run $i"; PYTHONPATH=. uv run python...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ S=&amp;lt;scratch&amp;gt; &amp;amp;&amp;amp; for i in 1 2; do echo "=== run $i"; PYTHONPATH=. uv run python "$S/accuracy2.py" gemma4:e4b 2&amp;gt;&amp;amp;1 | tee "$S/acc-e4b-v3-run$i.txt"; done&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;=== run 1
electricity.png                       10.3s calls=2 bill  'Sahyadri Power Distribution Ltd.' 1240.0   2026-10-09T00:00  problems=[]  OK
broadband.png 
… [3038 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/extract.py: replace 8 lines with 16&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/extract.py: replace 8 lines with 16&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/extract.py: replace 2 lines with 4&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/extract.py: replace 2 lines with 4&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Now the code side: &lt;code&gt;labelled_deadline()&lt;/code&gt; picks the single date whose printed label says it's a deadline, and that choice wins over the model's own &lt;code&gt;due_date&lt;/code&gt;.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/extract.py: replace 18 lines with 45&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/extract.py: replace 18 lines with 45&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/extract.py: replace 5 lines with 6&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/extract.py: replace 5 lines with 6&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;S=&amp;lt;scratch&amp;gt; &amp;amp;&amp;amp; PYTHONPATH=. uv run python "$S/accuracy2.py" gemma4:e4b 2&amp;gt;&amp;amp;1 |...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ S=&amp;lt;scratch&amp;gt; &amp;amp;&amp;amp; PYTHONPATH=. uv run python "$S/accuracy2.py" gemma4:e4b 2&amp;gt;&amp;amp;1 | tee "$S/acc-e4b-v4.txt"&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;electricity.png                        7.1s calls=1 bill  'Sahyadri Power Distribution Ltd.' 1240.0   2026-10-09T00:00  problems=[]  OK
broadband.png           
… [1460 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/extract.py: replace 2 lines with 3&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/extract.py: replace 2 lines with 3&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;S=&amp;lt;scratch&amp;gt; &amp;amp;&amp;amp; PYTHONPATH=. uv run python "$S/accuracy2.py" gemma4:e4b 2&amp;gt;&amp;amp;1 |...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ S=&amp;lt;scratch&amp;gt; &amp;amp;&amp;amp; PYTHONPATH=. uv run python "$S/accuracy2.py" gemma4:e4b 2&amp;gt;&amp;amp;1 | tee "$S/acc-e4b-v5.txt"&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;electricity.png                       10.9s calls=1 bill  'Sahyadri Power Distribution Ltd.' 1240.0   2026-10-09T00:00  problems=[]  OK
broadband.png           
… [1434 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/extract.py: replace 16 lines with 8&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/extract.py: replace 16 lines with 8&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/extract.py: replace 3 lines with 0&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/extract.py: replace 3 lines with 0&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/extract.py: replace 20 lines with 32&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/extract.py: replace 20 lines with 32&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/extract.py: replace 12 lines with 13&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/extract.py: replace 12 lines with 13&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/extract.py: replace 1 lines with 1&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/extract.py: replace 1 lines with 1&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/extract.py: replace 1 lines with 1&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/extract.py: replace 1 lines with 1&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/extract.py: replace 3 lines with 3&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/extract.py: replace 3 lines with 3&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;S=&amp;lt;scratch&amp;gt; &amp;amp;&amp;amp; for i in 1 2; do echo "=== run $i"; PYTHONPATH=. uv run python...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ S=&amp;lt;scratch&amp;gt; &amp;amp;&amp;amp; for i in 1 2; do echo "=== run $i"; PYTHONPATH=. uv run python "$S/accuracy2.py" gemma4:e4b 2&amp;gt;&amp;amp;1 | tee "$S/acc-e4b-v6-run$i.txt"; done&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;=== run 1
electricity.png                       10.7s calls=2 bill  'Sahyadri Power Distribution Ltd.' 1240.0   2026-10-09T00:00  problems=[]  OK
broadband.png 
… [3080 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-write"&gt;Write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;tests/unit/test_extract.py (267 lines)&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;tests&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;test_extract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;py &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;267&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;uv run poe test 2&amp;gt;&amp;amp;1 | grep -v Warning | tail -25&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ uv run poe test 2&amp;gt;&amp;amp;1 | grep -v Warning | tail -25&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Poe =&amp;gt; pytest tests/unit
============================= test session starts ==============================
platform darwin -- Python 3.12.2, pytest-9.1.1, pluggy
… [545 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;All 79 unit tests pass. Now I'll add the step-2 numbers and dead ends to BUILD_LOG, then commit and push.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
  &lt;/div&gt;

  &lt;div class="agent-session-footer"&gt;
    &lt;span class="agent-session-meta"&gt;
        47 of 47 messages
    &lt;/span&gt;
  &lt;/div&gt;
&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Part 2: the Temporal workflow, and a code review that changed the reminder schedule&lt;/strong&gt;&lt;/p&gt;


&lt;div class="ltag-agent-session"&gt;
  &lt;div class="agent-session-header"&gt;
    
    &lt;span class="agent-session-tool-icon-badge" title="Claude Code"&gt;
&lt;/span&gt;
    &lt;span class="agent-session-title"&gt;PaidYet build session, part 2: Temporal workflow and the code review&lt;/span&gt;
  &lt;/div&gt;

  &lt;div class="agent-session-scroll"&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/extract.py: replace 1 lines with 18&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/extract.py: replace 1 lines with 18&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-write"&gt;Write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/workflows.py (345 lines)&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;paidyet&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;workflows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;py &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;345&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;I left a pointless &lt;code&gt;_Unused&lt;/code&gt; dataclass at the end just to justify an import. Removing it, along with the import:&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/workflows.py: replace 6 lines with 1&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/workflows.py: replace 6 lines with 1&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/workflows.py: replace 2 lines with 2&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/workflows.py: replace 2 lines with 2&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/workflows.py: replace 13 lines with 13&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/workflows.py: replace 13 lines with 13&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/workflows.py: replace 2 lines with 7&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/workflows.py: replace 2 lines with 7&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The snooze handling has an edge case: a snooze that arrives just as a reminder fires could be lost. I'll restructure the loop to apply any new snooze at the top of each pass.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/workflows.py: replace 19 lines with 17&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/workflows.py: replace 19 lines with 17&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Now &lt;code&gt;store.py&lt;/code&gt;, the SQLite read model that &lt;code&gt;/due&lt;/code&gt; will use. It holds only the confirmed fields and IDs.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-write"&gt;Write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/store.py (96 lines)&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;paidyet&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;py &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;96&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;That &lt;code&gt;_row&lt;/code&gt; helper came out garbled (a dead &lt;code&gt;if False else&lt;/code&gt; branch). Cleaning it up:&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/store.py: replace 6 lines with 4&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/store.py: replace 6 lines with 4&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-write"&gt;Write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/bot.py (426 lines)&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;paidyet&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;py &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;426&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Same mistake again: a stray &lt;code&gt;assert&lt;/code&gt; to justify an unused import. Removing both:&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/bot.py: replace 5 lines with 1&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/bot.py: replace 5 lines with 1&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/bot.py: replace 1 lines with 1&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/bot.py: replace 1 lines with 1&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-write"&gt;Write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/activities.py (204 lines)&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;paidyet&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;activities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;py &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;204&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The cleanup in &lt;code&gt;fix_draft&lt;/code&gt; is convoluted (&lt;code&gt;"draft" in locals()&lt;/code&gt;). I'll make it explicit: forget the correction text after a successful read, or on the final failed attempt.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/activities.py: replace 9 lines with 11&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/activities.py: replace 9 lines with 11&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/activities.py: replace 1 lines with 10&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/activities.py: replace 1 lines with 10&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/workflows.py: replace 6 lines with 9&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/workflows.py: replace 6 lines with 9&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Now wiring the worker and the bot together in &lt;code&gt;main.py&lt;/code&gt;. I'm also adding a background model warm-up at start, so the first message doesn't pay the ~22 s cold load.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-read"&gt;Read&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;main.py&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;main.py: replace 23 lines with 47&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;main.py: replace 23 lines with 47&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;main.py: replace 6 lines with 11&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;main.py: replace 6 lines with 11&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-write"&gt;Write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;tests/unit/test_workflow.py (291 lines)&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;tests&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;test_workflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;py &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;291&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;uv run pytest tests/unit/test_workflow.py -x -q 2&amp;gt;&amp;amp;1 | tail -40; echo "== cac...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ uv run pytest tests/unit/test_workflow.py -x -q 2&amp;gt;&amp;amp;1 | tail -40; echo "== cache"; ls -la .pytest_cache/temporal 2&amp;gt;/dev/null | head; ls "${TMPDIR:-/tmp}" | grep 
… [63 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Pytest: 0 passed, 1 failed

Failures:
1. [FAIL] test_save_remind_snooze_remind_paid
     tests/unit/test_workflow.py:156: in test_save_remind_snooze_remind_paid
… [277 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;tests/unit/test_workflow.py: replace 1 lines with 9&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;tests/unit/test_workflow.py: replace 1 lines with 9&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;tests/unit/test_workflow.py: replace 2 lines with 2&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;tests/unit/test_workflow.py: replace 2 lines with 2&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;uv run pytest tests/unit/test_workflow.py -q 2&amp;gt;&amp;amp;1 | tail -30&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ uv run pytest tests/unit/test_workflow.py -q 2&amp;gt;&amp;amp;1 | tail -30&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Command did not complete within its 600s timeout and was moved to the background (ID: bs4cgv1c4). Output is being written to: &amp;lt;scratch&amp;gt; You will be notified whe
… [165 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The run hung past 10 minutes, so something is blocking. I'll check how far it got.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;pkill -f temporal-test-server; for t in test_save_remind_snooze_remind_paid t...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ pkill -f temporal-test-server; for t in test_save_remind_snooze_remind_paid test_flaky_reads_are_retried test_final_read_failure_still_deletes_the_input test_in
… [372 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;=== test_save_remind_snooze_remind_paid
[2m2026-10-03T20:00:43.756227Z[0m [33m WARN[0m [2mtemporalio_sdk_core::worker[0m[2m:[0m Temporal Server 1.16.0 o
… [1047 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Run one at a time, nothing hangs, but three fail. I'll get the full tracebacks.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;for t in test_save_remind_snooze_remind_paid test_flaky_reads_are_retried tes...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ for t in test_save_remind_snooze_remind_paid test_flaky_reads_are_retried test_final_read_failure_still_deletes_the_input; do echo "=== $t"; uv run python -m py
… [168 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;=== test_save_remind_snooze_remind_paid
     tests/unit/test_workflow.py:174: in test_save_remind_snooze_remind_paid
     assert fakes.payloads("edit_reminder")
… [622 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/workflows.py: replace 14 lines with 15&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/workflows.py: replace 14 lines with 15&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;tests/unit/test_workflow.py: replace 2 lines with 3&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;tests/unit/test_workflow.py: replace 2 lines with 3&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;tests/unit/test_workflow.py: replace 4 lines with 6&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;tests/unit/test_workflow.py: replace 4 lines with 6&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;pkill -f temporal-test-server; for t in test_save_remind_snooze_remind_paid t...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ pkill -f temporal-test-server; for t in test_save_remind_snooze_remind_paid test_flaky_reads_are_retried test_final_read_failure_still_deletes_the_input test_fi
… [650 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;test_save_remind_snooze_remind_paid                            1 passed in 0.96s 
test_flaky_reads_are_retried                                   1 passed in 0.3
… [494 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;python3 - &amp;lt;&amp;lt;'EOF'
import re
p = "tests/unit/test_workflow.py"
s = open(p).rea...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ python3 - &amp;lt;&amp;lt;'EOF'
import re
p = "tests/unit/test_workflow.py"
s = open(p).read()
s, n = re.subn(r"    worker, handle = await start\((env, fakes[^)]*)\)\n    asy
… [1079 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;replaced 9
9
collected 88 items

tests/unit/test_config.py .............                                  [ 14%]
tests/unit/test_extract.py ....................
… [274 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-write"&gt;Write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;tests/unit/test_bot.py (109 lines)&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;tests&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;test_bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;py &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;109&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;uv run poe test 2&amp;gt;&amp;amp;1 | grep -v 'WARN\|Warning' | tail -15&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ uv run poe test 2&amp;gt;&amp;amp;1 | grep -v 'WARN\|Warning' | tail -15&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;tests/unit/test_workflow.py .........                                    [100%]

=================================== FAILURES ==================================
… [871 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;A real bug: &lt;code&gt;.capitalize()&lt;/code&gt; lowercased the rest of the line, giving "was due fri 9 oct". It should uppercase only the first letter.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/bot.py: replace 1 lines with 2&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/bot.py: replace 1 lines with 2&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;uv run poe test 2&amp;gt;&amp;amp;1 | grep -v 'WARN\|Warning' | tail -3 &amp;amp;&amp;amp; echo "=== smoke: ...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ uv run poe test 2&amp;gt;&amp;amp;1 | grep -v 'WARN\|Warning' | tail -3 &amp;amp;&amp;amp; echo "=== smoke: poe up without .env (worker must pass sandbox validation)" &amp;amp;&amp;amp; S=&amp;lt;scratch&amp;gt; &amp;amp;&amp;amp; uv run
… [152 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;tests/unit/test_workflow.py .........                                    [100%]

============================= 106 passed in 2.25s =============================
… [1043 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;&lt;code&gt;poe up&lt;/code&gt; now starts the worker, which means the workflow passed Temporal's sandbox validation, and shutdown is still clean. All 106 unit tests pass. Logging step 3 and committing:&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div&gt;
                  &lt;div class="agent-session-text agent-session-text-collapse"&gt;
                    &lt;p&gt;Code review from Prateek's planning session. A second Claude instance read the whole repo at f61a137.&lt;br&gt;
This review comes BEFORE any live testing. Don't start the stack, and don't run the integration tests or anything else live yet.&lt;br&gt;
Work through the points below, run the unit tests, commit, and then report back. We'll do real-keys testing after that.&lt;/p&gt;

&lt;p&gt;Keep as is (no churn needed):&lt;br&gt;
- privacy handling (keys instead of text, photo discard in &lt;code&gt;finally&lt;/code&gt;, start-up sweep);&lt;br&gt;
- the who-may-tap-what checks via the status query;&lt;br&gt;
- pid-file stop-deps;&lt;br&gt;
- loopback guards;&lt;br&gt;
- Sentry scrubbing;&lt;br&gt;
- parameterised SQL.&lt;/p&gt;

&lt;p&gt;== A. Reminder timing. This is a product decision, so confirm the proposed schedule with Prateek before implementing it.&lt;/p&gt;

&lt;p&gt;What the code does today (workflows.py &lt;code&gt;first_reminder&lt;/code&gt; / &lt;code&gt;next_nudge&lt;/code&gt; / &lt;code&gt;nudge_interval&lt;/code&gt;, extract.py &lt;code&gt;parse_relative&lt;/code&gt;):&lt;br&gt;
- &lt;strong&gt;Date dues&lt;/strong&gt; ("Friday tak", a bill due 12 Oct):&lt;br&gt;
  - a reminder at 7 PM the evening before;&lt;br&gt;
  - then 10 AM on the due day;&lt;br&gt;
  - then 10 AM daily until Paid.&lt;br&gt;
  There's nothing earlier, however far away the due date is: an insurance renewal due in 3 weeks gets its first reminder the night before.&lt;br&gt;
- &lt;strong&gt;Timed dues&lt;/strong&gt; exist only for "in N seconds/minutes/hours"; clock times like "Friday 5 PM" aren't supported.&lt;br&gt;
  - They fire exactly AT the deadline, with no lead time.&lt;br&gt;
  - They then repeat every (due − sent_at), capped at a day, around the clock. Real "in 5 hours" nudges every 5 h, including 3 AM. Real "in 5 minutes" nudges every 5 min forever.&lt;br&gt;
  - So t&lt;br&gt;
… [3338 more characters trimmed]&lt;/p&gt;

                  &lt;/div&gt;
                  Show more
                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Points A–C are a review I'll act on. You've asked me to confirm A (the reminder schedule) with you before implementing it, and some of B depends on that schedule. So the schedule question comes first; everything else gets implemented right after. Nothing runs live.&lt;/p&gt;

&lt;p&gt;My take on A: I agree with all five points. There are three gaps the proposal leaves open, and I've folded my suggested answers into the options below.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-askuserquestion"&gt;AskUserQuestion&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;Approve the proposed reminder schedule (A1–A5), with these gaps filled: timed...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Approve the proposed reminder schedule (A1–A5), with these gaps filled: timed dues 10 min–1 h away get one reminder at the deadline and then daily 10 AM; demo d
… [464 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Your questions have been answered: "Approve the proposed reminder schedule (A1–A5), with these gaps filled: timed dues 10 min–1 h away get one reminder at the d
… [907 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Approved, including the gap-fillers, with pre-deadline quiet-hour reminders moved earlier to 21:30. I'll reread the current &lt;code&gt;workflows.py&lt;/code&gt; and &lt;code&gt;bot.py&lt;/code&gt; before changing the schedule code.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-read"&gt;Read&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/workflows.py&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;paidyet&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;workflows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;I'll check how much RAM Gemma uses while resident (item 10). The model blob size on disk is a fair proxy, and reading it doesn't need the stack.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-write"&gt;Write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/workflows.py (517 lines)&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;paidyet&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;workflows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;py &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;517&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/workflows.py: replace 10 lines with 10&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/workflows.py: replace 10 lines with 10&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/workflows.py: replace 3 lines with 3&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/workflows.py: replace 3 lines with 3&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/workflows.py: replace 8 lines with 9&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/workflows.py: replace 8 lines with 9&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/workflows.py: replace 7 lines with 7&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;paidyet/workflows.py: replace 7 lines with 7&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Now &lt;code&gt;activities.py&lt;/code&gt;: split &lt;code&gt;mark_paid&lt;/code&gt; (SQLite, bounded retries) from &lt;code&gt;show_paid&lt;/code&gt; (Telegram), treat Telegram's &lt;code&gt;BadRequest&lt;/code&gt; as final, and give the "added" and "last" messages only a ✅ Paid button.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-read"&gt;Read&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/activities.py&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;paidyet&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;activities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;python3 - &amp;lt;&amp;lt;'EOF'
p = "paidyet/activities.py"
s = open(p).read()

s = s.repla...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ python3 - &amp;lt;&amp;lt;'EOF'
p = "paidyet/activities.py"
s = open(p).read()

s = s.replace('''def _gone(what: str) -&amp;gt; ApplicationError:''', '''def _refused(e: Exception) -
… [3796 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;31:def _refused(e: Exception) -&amp;gt; ApplicationError:
71:            self.show_paid,
150:            raise _refused(e) from e
162:            raise _refused(e) fro
… [211 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-read"&gt;Read&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;paidyet/bot.py&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;paidyet&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;sed -i '' 's/        return f"every morning until it'"'"'s paid"/        retu...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ sed -i '' 's/        return f"every morning until it'"'"'s paid"/        return "every morning until it'"'"'s paid"/' paidyet/bot.py &amp;amp;&amp;amp; grep -n "def capture" -A
… [29 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;371:async def capture(update: Update, context: ContextTypes.DEFAULT_TYPE) -&amp;gt; None:
372-    msg, user, settings = update.effective_message, update.effective_user
… [1541 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;python3 - &amp;lt;&amp;lt;'EOF'
p = "tests/unit/test_bot.py"
s = open(p).read()
s = s[: s.i...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ python3 - &amp;lt;&amp;lt;'EOF'
p = "tests/unit/test_bot.py"
s = open(p).read()
s = s[: s.index("# --- schedule rules (pure functions in workflows.py) ---")]
s = s.replace("f
… [7856 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;31 passed in 0.14s&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;All 31 schedule and message tests pass on the first run. Now the workflow tests. I'll reread the current file before editing.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-read"&gt;Read&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;tests/unit/test_workflow.py&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;tests&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;test_workflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;python3 - &amp;lt;&amp;lt;'EOF'
p = "tests/unit/test_workflow.py"
s = open(p).read()

def r...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ python3 - &amp;lt;&amp;lt;'EOF'
p = "tests/unit/test_workflow.py"
s = open(p).read()

def rep(old, new, count=1):
    global s
    assert s.count(old) == count, (s.count(old)
… [13015 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;# Do call (this throws an RPCError on failure)
            raise ValueError(f"Unrecognized service {service}")
E       temporal_sdk_bridge.RPCError: (1, 'Timeou
… [1002 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;An RPC "Timeout expired" at line 387, inside one of the new tests. I'll find which one.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;sed -n '380,392p' tests/unit/test_workflow.py; uv run pytest tests/unit/test_...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ sed -n '380,392p' tests/unit/test_workflow.py; uv run pytest tests/unit/test_workflow.py -q -p no:logging --tb=no 2&amp;gt;&amp;amp;1 | grep -E 'PASSED|FAILED|passed|failed' |
… [99 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Exit code 1
async def test_sqlite_failures_are_bounded_and_surfaced(env):
    now = await now_ist(env)
    fakes = Fakes(reads=[draft(now + timedelta(days=5))],
… [644 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;uv run python -m pytest tests/unit/test_workflow.py -q -rf --tb=line -p no:lo...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ uv run python -m pytest tests/unit/test_workflow.py -q -rf --tb=line -p no:logging 2&amp;gt;&amp;amp;1 | grep -v WARN | tail -8&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Failures:
     E   temporalio.service.RPCError: Timeout expired
     raise RuntimeError("database is locked")
     RuntimeError: database is locked

2. [FAIL] t
… [232 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;grep -nE "^(E |    |tests/)|Error|assert" &amp;lt;local path&amp;gt; Support/rtk/tee/&amp;lt;id&amp;gt;_p...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ grep -nE "^(E |    |tests/)|Error|assert" &amp;lt;local path&amp;gt; Support/rtk/tee/&amp;lt;id&amp;gt;_pytest.log | grep -v 'site-packages\|^[0-9]*:    [a-z_]*\.' | grep -E 'test_workflow
… [27 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;3:E   temporal_sdk_bridge.RPCError: (1, 'Timeout expired', b'')
6:E   temporalio.service.RPCError: Timeout expired&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;for t in test_sqlite_failures_are_bounded_and_surfaced test_after_30_days_ove...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ for t in test_sqlite_failures_are_bounded_and_surfaced test_after_30_days_overdue_it_says_so_once_and_waits_in_due; do echo "=== $t"; uv run python -m pytest "t
… [105 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;=== test_sqlite_failures_are_bounded_and_surfaced
Pytest: 1 passed
=== test_after_30_days_overdue_it_says_so_once_and_waits_in_due
Pytest: 1 passed&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Both pass on their own, so something earlier in the suite leaks into them. The likeliest culprit is the test that runs just before them. I'll check pairs.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;T=tests/unit/test_workflow.py; for prev in test_a_snooze_never_moves_the_next...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ T=tests/unit/test_workflow.py; for prev in test_a_snooze_never_moves_the_next_reminder_earlier test_one_sentry_trace_from_the_telegram_update_to_every_activity 
… [234 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;test_a_snooze_never_moves_the_next_reminder_earlier + sqlite           Pytest: 1 passed, 1 failed
test_one_sentry_trace_from_the_telegram_update_to_every_activi
… [116 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;cp tests/unit/test_workflow.py tests/unit/test_zz_debug.py &amp;amp;&amp;amp; python3 - &amp;lt;&amp;lt;'EO...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ cp tests/unit/test_workflow.py tests/unit/test_zz_debug.py &amp;amp;&amp;amp; python3 - &amp;lt;&amp;lt;'EOF'
p = "tests/unit/test_zz_debug.py"
s = open(p).read()
s = s.replace('''          
… [783 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;BEFORE 1
AFTER 5
1 passed in 0.90s&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;uv run python -m pytest "tests/unit/test_workflow.py::test_sqlite_failures_ar...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ uv run python -m pytest "tests/unit/test_workflow.py::test_sqlite_failures_are_bounded_and_surfaced" "tests/unit/test_workflow.py::test_flaky_reads_are_retried"
… [77 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;1:Pytest: 2 passed&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;/usr/bin/time -p uv run python -m pytest "tests/unit/test_workflow.py::test_s...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ /usr/bin/time -p uv run python -m pytest "tests/unit/test_workflow.py::test_sqlite_failures_are_bounded_and_surfaced" -q -p no:logging -p no:cacheprovider 2&amp;gt;&amp;amp;1 
… [306 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;2:1 passed in 0.32s
3:real 0.70
3:E   temporal_sdk_bridge.RPCError: (1, 'Timeout expired', b'')
6:E   temporalio.service.RPCError: Timeout expired
10:Completing
… [1590 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;python3 - &amp;lt;&amp;lt;'EOF'
p = "tests/unit/test_workflow.py"
s = open(p).read()
old = ...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ python3 - &amp;lt;&amp;lt;'EOF'
p = "tests/unit/test_workflow.py"
s = open(p).read()
old = '''        finally:
            with contextlib.suppress(Exception):  # by ID, so a
… [1051 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;============================= 137 passed in 6.09s ==============================&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;All 137 pass. Since that was a timing bug, I'll run the suite twice more to check it's stable, not lucky.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;for i in 1 2; do uv run poe test 2&amp;gt;&amp;amp;1 | grep -E 'passed|failed' | tail -1; done&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ for i in 1 2; do uv run poe test 2&amp;gt;&amp;amp;1 | grep -E 'passed|failed' | tail -1; done&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;============================= 137 passed in 5.62s ==============================
============================= 137 passed in 5.73s =============================
… [1 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Updating README, the post and BUILD_LOG to the new schedule. The README also gets the keep-alive RAM trade-off, &lt;code&gt;/cancel&lt;/code&gt;, "Snooze only postpones", and the 30-day stop.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Now the BUILD_LOG entry for this round:&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;cat &amp;gt;&amp;gt; docs/BUILD_LOG.md &amp;lt;&amp;lt;'EOF'

## Sun 4 Oct, ~03:00: code review before li...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ cat &amp;gt;&amp;gt; docs/BUILD_LOG.md &amp;lt;&amp;lt;'EOF'

## Sun 4 Oct, ~03:00: code review before live testing

A second Claude instance reviewed the repo at `f61a137`. Prateek confir
… [5215 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;============================= 137 passed in 6.15s ==============================
M  README.md
M  docs/BUILD_LOG.md
M  docs/devto-post.md
M  main.py
M  paidyet/a
… [244 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div&gt;
                  &lt;div class="agent-session-text agent-session-text-collapse"&gt;
                    &lt;p&gt;I worked through all ten review points and committed them as &lt;code&gt;add5481&lt;/code&gt;. &lt;strong&gt;137 unit tests pass&lt;/strong&gt;, stable over three runs. I didn't start the stack or run the integration tests; I only checked that they still collect.&lt;/p&gt;

&lt;h2&gt;A. Reminder schedule (built as you approved it)&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Date dues:&lt;/strong&gt; 10 AM three days before (only if saved at least 3 days ahead), 7 PM the evening before, 10 AM and 7 PM on the day, then 10 AM daily while overdue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timed dues:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;at least 1 hour away: 30 minutes before, then at the deadline;&lt;/li&gt;
&lt;li&gt;10 minutes to 1 hour away: once, at the deadline;&lt;/li&gt;
&lt;li&gt;under 10 minutes (demo): 
… [3265 more characters trimmed]&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

                  &lt;/div&gt;
                  Show more
                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
  &lt;/div&gt;

  &lt;div class="agent-session-footer"&gt;
    &lt;span class="agent-session-meta"&gt;
        47 of 47 messages
    &lt;/span&gt;
  &lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;Part 3: live testing in Telegram, and killing the stack mid-wait&lt;/strong&gt;&lt;/p&gt;


&lt;div class="ltag-agent-session"&gt;
  &lt;div class="agent-session-header"&gt;
    
    &lt;span class="agent-session-tool-icon-badge" title="Claude Code"&gt;
&lt;/span&gt;
    &lt;span class="agent-session-title"&gt;PaidYet build session, part 3: live testing and the kill-and-restart proof&lt;/span&gt;
  &lt;/div&gt;

  &lt;div class="agent-session-scroll"&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div&gt;
                  &lt;div class="agent-session-text agent-session-text-collapse"&gt;
                    &lt;p&gt;env ready. The review fixes at add5481 look good. Prateek has filled in .env. Don't read or print it; rely on the app's own start-up log lines ("not set" / "enabled") to confirm what's present.&lt;/p&gt;

&lt;p&gt;Now run the live testing, in this order:&lt;br&gt;
1. &lt;strong&gt;Start the stack&lt;/strong&gt; with &lt;code&gt;uv run poe up&lt;/code&gt;, as a background process you can watch and stop. Confirm from the logs:&lt;br&gt;
   - Ollama and Temporal are up;&lt;br&gt;
   - the model is loaded;&lt;br&gt;
   - Sentry is enabled;&lt;br&gt;
   - Telegram is polling, with the bot's @username shown.&lt;br&gt;
   Then tell Prateek the bot's username, so he can open it in Telegram.&lt;br&gt;
2. Run &lt;code&gt;uv run poe test&lt;/code&gt; (unit), then &lt;code&gt;uv run poe test-integration&lt;/code&gt; against the running stack. Integration tests message only the admin, prefixed "[test]", so Prateek should see those arrive. Fix any failures with small commits, then re-run.&lt;br&gt;
3. &lt;strong&gt;Live run with Prateek in Telegram,&lt;/strong&gt; covering the section 12 checklist:&lt;br&gt;
   - bill photo → confirm → reminder → Snooze → Paid. Use a demo due like "in 2 minutes" so it finishes quickly;&lt;br&gt;
   - a Hinglish IOU text;&lt;br&gt;
   - /due;&lt;br&gt;
   - the admin's /remind for arjun (only if a second account is in ALLOWED_USERS; otherwise say so and skip it);&lt;br&gt;
   - an unknown user being turned away;&lt;br&gt;
   - the kill-and-restart proof (stop the app mid-wait, restart it, and the reminder still arrives late);&lt;br&gt;
   - &lt;code&gt;.data/tmp/&lt;/code&gt; empty afterwards;&lt;br&gt;
   - Sentry traces arriving with no bill contents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to work with Prateek:&lt;/strong&gt;&lt;br&gt;
- Whenever you need him to act on his phone, stop and end your turn with a short "👉 Your &lt;br&gt;
… [609 more characters trimmed]&lt;/p&gt;

                  &lt;/div&gt;
                  Show more
                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Starting the stack. I'll launch &lt;code&gt;uv run poe up&lt;/code&gt; in its own process session, logging to a scratch file, so I can watch it and later stop it with a real Ctrl-C (SIGINT to the group), which lets the cleanup trap run.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;S=&amp;lt;scratch&amp;gt; &amp;amp;&amp;amp; cat &amp;gt; $S/launch_up.py &amp;lt;&amp;lt;'EOF'
"""Start `uv run poe up` in its ...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ S=&amp;lt;scratch&amp;gt; &amp;amp;&amp;amp; cat &amp;gt; $S/launch_up.py &amp;lt;&amp;lt;'EOF'
"""Start `uv run poe up` in its own session (like a terminal tab), logging to a file; record its process group."""

… [662 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;nothing running yet
started, process group 55180
ollama:   started on 127.0.0.1:11434 (pid 55211)
ollama:   model gemma4:e4b ready
temporal: started on 127.0.0.
… [13162 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;pgrep -fl 'ollama serve|temporal server|python main.py' || echo "all stopped ...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ pgrep -fl 'ollama serve|temporal server|python main.py' || echo "all stopped (cleanup ran)"; ls .data/run/; for i in 1 2 3; do curl -sS -m 15 -o /dev/null -w "a
… [118 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;all stopped (cleanup ran)
(empty)
api.telegram.org: HTTP 302, connect 0.168822s, total 0.516032s
api.telegram.org: HTTP 302, connect 0.174461s, total 0.523979s

… [62 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;python3 - &amp;lt;&amp;lt;'EOF'
p = "main.py"
s = open(p).read()
old = "            await a...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ python3 - &amp;lt;&amp;lt;'EOF'
p = "main.py"
s = open(p).read()
old = "            await app.updater.start_polling()\n"
new = "            # Keep retrying the bootstrap: one
… [1348 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;============================= 137 passed in 6.18s ==============================
started, process group 56854
ollama:   started on 127.0.0.1:11434 (pid 56874)
o
… [596 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;S=&amp;lt;scratch&amp;gt; for _ in $(seq 60); do grep -q "Ollama:" $S/up.log &amp;amp;&amp;amp; break; slee...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ S=&amp;lt;scratch&amp;gt; for _ in $(seq 60); do grep -q "Ollama:" $S/up.log &amp;amp;&amp;amp; break; sleep 1; done; grep "Ollama:" $S/up.log; echo "=== integration"; /usr/bin/time -p uv ru
… [137 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;2026-10-04 02:31:16,169 INFO paidyet: Ollama: gemma4:e4b loaded in 0 ms
=== integration
E               AssertionError: timed out; last status: Status(state='sc
… [543 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;python3 - &amp;lt;&amp;lt;'EOF'
def edit(path, old, new):
    s = open(path).read()
    ass...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ python3 - &amp;lt;&amp;lt;'EOF'
def edit(path, old, new):
    s = open(path).read()
    assert s.count(old) == 1, (path, old[:60])
    open(path, "w").write(s.replace(old, ne
… [2530 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;============================= 137 passed in 6.17s ==============================
d826acc Set next_at before sending a reminder; time the warm-up&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Committed (&lt;code&gt;d826acc&lt;/code&gt;). Restarting the stack to load the new workflow code, using a real Ctrl-C so the cleanup runs, then re-running the integration tests.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;S=&amp;lt;scratch&amp;gt; kill -INT -- -$(cat $S/up.pgid); for _ in $(seq 30); do pgrep -f ...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ S=&amp;lt;scratch&amp;gt; kill -INT -- -$(cat $S/up.pgid); for _ in $(seq 30); do pgrep -f 'python main.py|temporal server|ollama serve' &amp;gt;/dev/null || break; sleep 1; done; p
… [462 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;stopped cleanly
2026-10-04 02:35:25,451 INFO temporalio.worker._worker: Beginning worker shutdown, will wait 0:00:00 before cancelling activities
ollama: stoppe
… [559 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div&gt;
                  &lt;div class="agent-session-text agent-session-text-collapse"&gt;
                    &lt;p&gt;All &lt;strong&gt;5 integration tests pass&lt;/strong&gt; against the real stack (2 min 18 s), using real Gemma, Temporal, SQLite and Telegram. You should have seen &lt;code&gt;[test]&lt;/code&gt; messages arrive in your chat. The model warmed up in 7 s.&lt;/p&gt;

&lt;p&gt;So far, live testing has found and fixed three things (three small commits):&lt;br&gt;
- &lt;strong&gt;Start-up crash on one slow Telegram response.&lt;/strong&gt; The polling bootstrap now retries until it succeeds, with longer timeouts.&lt;br&gt;
- &lt;strong&gt;A status race around sending a reminder.&lt;/strong&gt; The next reminder is now computed &lt;em&gt;before&lt;/em&gt; sending, so a Snooze is never checked against a stale time.&lt;br&gt;
- &lt;strong&gt;A misleading warm-up log line&lt;/strong&gt; ("&lt;br&gt;
… [927 more characters trimmed]&lt;/p&gt;

                  &lt;/div&gt;
                  Show more
                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;I sent it from the admin chat. I sent an electricity bill photo and then saved it. Check if the records have been saved and check if Temporal is awaiting now on the reminders to the workflow. Also tell me which endpoint I should check on localhost so that I can see my Temporal workflow and be able to attach screenshots later in the article when we require&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;I'll check three places: the app log for the read, the SQLite record, and the workflow in Temporal (its status and the pending timer). I'll print only the fields we need, with no Telegram IDs.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div&gt;
                  &lt;div class="agent-session-text agent-session-text-collapse"&gt;
                    &lt;p&gt;Everything is saved correctly, and Temporal is durably waiting on the next reminder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happened with your photo:&lt;/strong&gt;&lt;br&gt;
- &lt;strong&gt;Read:&lt;/strong&gt; Gemma took one call, about 7.4 s. Code accepted the result: Electricity Bill · ₹1,240 · Sahyadri Power Distribution Ltd. · due Fri 9 Oct.&lt;br&gt;
- &lt;strong&gt;Photo deleted:&lt;/strong&gt; &lt;code&gt;discard_input&lt;/code&gt; ran right after the read, and &lt;code&gt;.data/tmp/&lt;/code&gt; is empty. An earlier check of mine said "1 file", but that was a shell-display quirk; Python confirms 0 files.&lt;br&gt;
- &lt;strong&gt;Saved to SQLite:&lt;/strong&gt; one row with the confirmed fields only, &lt;code&gt;paid_at&lt;/code&gt; empty.&lt;br&gt;
- &lt;strong&gt;Temporal:&lt;/strong&gt; workflow &lt;code&gt;r-&amp;lt;id&amp;gt;&lt;/code&gt; is &lt;strong&gt;RUNNING&lt;/strong&gt; in state &lt;br&gt;
… [1636 more characters trimmed]&lt;/p&gt;

                  &lt;/div&gt;
                  Show more
                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;And said it is due in 2 minutes and then saved it as well.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;I'll check the Fix, the save and the timer on the new workflow, and whether the first reminder has gone out.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The Fix worked. Gemma applied "due in 2 minutes" in 3.4 s, making it due &lt;strong&gt;02:45:19&lt;/strong&gt;, and the first reminder is scheduled for that exact moment. I'll watch for it to go out:&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div&gt;
                  &lt;div class="agent-session-text agent-session-text-collapse"&gt;
                    &lt;p&gt;The reminder &lt;strong&gt;went out at 02:45:19&lt;/strong&gt;, right on time; the workflow says 1 sent and the message is posted. The next demo reminder is due at 02:47:19.&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;Your turn&lt;/strong&gt; (admin account), on the ⏰ reminder message:&lt;br&gt;
1. Tap &lt;strong&gt;😴 Snooze → Tomorrow&lt;/strong&gt;. You should get a toast "Snoozed until Mon 5 Oct, 10 AM", and the reminder should change to add "😴 Snoozed until Mon 5 Oct, 10 AM", still with Paid / Snooze. If a second ⏰ arrives before you tap (they repeat every 2 min until snoozed), use the newest one; the older one loses its buttons.&lt;br&gt;
2. Then tap &lt;strong&gt;✅ Paid&lt;/strong&gt; on that same message. It should become "✅ Paid &lt;br&gt;
… [131 more characters trimmed]&lt;/p&gt;

                  &lt;/div&gt;
                  Show more
                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;I tapped Pay and I think it is all done. Confirm it once and then we will be done with the flow.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;I'll confirm from Temporal's history (did the snooze and the paid signal both land, and did the workflow finish?), SQLite and the temp folder.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;echo "=== workflow history (IST)"; temporal workflow show -w r-&amp;lt;id&amp;gt; --address...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ echo "=== workflow history (IST)"; temporal workflow show -w r-&amp;lt;id&amp;gt; --address 127.0.0.1:7233 -o json 2&amp;gt;/dev/null | python3 -c '
import json,sys
from datetime im
… [1441 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;=== workflow history (IST)
02:43:08 activity read_input
02:43:08 activity discard_input
02:43:09 activity show_confirm
02:43:09 timer 604800s
02:43:19 SIGNAL fi
… [877 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div&gt;
                  &lt;div class="agent-session-text agent-session-text-collapse"&gt;
                    &lt;p&gt;Paid is confirmed end to end:&lt;br&gt;
- &lt;strong&gt;Workflow &lt;code&gt;r-&amp;lt;id&amp;gt;&lt;/code&gt; is COMPLETED, result &lt;code&gt;"paid"&lt;/code&gt;.&lt;/strong&gt; Its history: read → discard → confirm → &lt;code&gt;fix&lt;/code&gt; → updated confirm → &lt;code&gt;save&lt;/code&gt; → a 107 s timer → reminder sent at 02:45:20 → &lt;code&gt;paid&lt;/code&gt; at 02:45:26 → SQLite marked paid → Telegram message edited to "✅ Paid".&lt;br&gt;
- &lt;strong&gt;SQLite:&lt;/strong&gt; &lt;code&gt;paid_at&lt;/code&gt; is set and &lt;code&gt;next_at&lt;/code&gt; cleared, so it's gone from &lt;code&gt;/due&lt;/code&gt;. The Tuesday electricity reminder is still pending, as it should be.&lt;br&gt;
- &lt;strong&gt;&lt;code&gt;.data/tmp&lt;/code&gt;:&lt;/strong&gt; 0 files.&lt;/p&gt;

&lt;p&gt;One gap: &lt;strong&gt;no &lt;code&gt;snooze&lt;/code&gt; signal is in the history.&lt;/strong&gt; Paid came 6 s after the reminder, so Snooze hasn't been tested live yet. That leaves one &lt;br&gt;
… [679 more characters trimmed]&lt;/p&gt;

                  &lt;/div&gt;
                  Show more
                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;We are not testing the other user one. We can save that piece but we can assume that it will all work fine because it worked fine for us in the admin profile.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Understood: &lt;code&gt;/remind&lt;/code&gt; and the unknown-user check are &lt;strong&gt;skipped live&lt;/strong&gt;. I'll mark them that way in the report. Both are unit-tested, but I won't claim they were verified live. I'll check whether the Hinglish IOU has come in yet.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div&gt;
                  &lt;div class="agent-session-text agent-session-text-collapse"&gt;
                    &lt;p&gt;Live testing is done; /remind and the unknown-user check stay skipped. If the Hinglish IOU / Snooze check you were on has landed, report it in one line. Then work through the steps below in order. Stop at each 👉 point and wait for Prateek.&lt;/p&gt;

&lt;h2&gt;1. Two last checks&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Kill-and-restart proof:&lt;/strong&gt; do it yourself. Use a pending demo reminder, or ask Prateek once for a fresh "Rahul ko 500 dene hai in 3 minutes". Stop the app mid-wait with a real Ctrl-C, wait until the due time has passed, then start it again. Confirm from Temporal's history that the reminder went out late and wasn't lost, and note the timings for BUILD_LOG and the post.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sentry:&lt;/strong&gt; check that traces arrived. Tell Prateek exactly which view to open and which trace (by operation name and time) so he can confirm by eye that there's no bill text. He takes the screenshots.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;[… the remaining steps of this plan are omitted from the curated session]&lt;/p&gt;

                  &lt;/div&gt;
                  Show more
                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;First, whether the Hinglish IOU landed:&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div&gt;
                  &lt;div class="agent-session-text agent-session-text-collapse"&gt;
                    &lt;p&gt;The Hinglish IOU never landed: there's no new read in the log or SQLite. So Snooze is still untested by tap. It's covered by the live integration test, which sent the snooze signal directly, plus the unit tests.&lt;/p&gt;

&lt;p&gt;For the kill-and-restart proof I need a pending demo reminder. There isn't one, and I can't create one myself, because typed text only lives in the running app's memory. So I'm asking you once.&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;Your turn&lt;/strong&gt; (admin account)&lt;br&gt;
1. Type: &lt;code&gt;Rahul ko 500 dene hai in 3 minutes&lt;/code&gt;&lt;br&gt;
2. Tap &lt;strong&gt;✅ Save&lt;/strong&gt;, and tell me &lt;strong&gt;right away&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Then I'll press Ctrl-C on the whole stack (app &lt;em&gt;and&lt;/em&gt; Temporal) whi&lt;br&gt;
… [226 more characters trimmed]&lt;/p&gt;

                  &lt;/div&gt;
                  Show more
                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;sent and saved, go ahead&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Finding the new workflow and its due time, then stopping the whole stack with a real Ctrl-C:&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;S=&amp;lt;scratch&amp;gt; pgrep -fl 'python main.py|temporal server|ollama serve' | cut -c1...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ S=&amp;lt;scratch&amp;gt; pgrep -fl 'python main.py|temporal server|ollama serve' | cut -c1-80; echo "=== last log lines (no tracebacks)"; grep -v '^\s' $S/up.log | grep -v '
… [113 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;60105 ollama serve
60123 temporal server start-dev --db-filename .data/temporal.d
60162 python main.py
=== last log lines (no tracebacks)
3:temporal: started on
… [1726 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;It just arrived: the read finished at &lt;strong&gt;02:55:35&lt;/strong&gt;. Gemma took 12.5 s this time, so my first check ran a few seconds too early. The Save may not have landed yet. Checking the workflow state:&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The Hinglish IOU landed and is saved: &lt;strong&gt;Rahul · ₹500 · due 02:57:21&lt;/strong&gt;, state &lt;code&gt;scheduled&lt;/code&gt;, 0 reminders sent. Pressing Ctrl-C on the whole stack now, with the timer about 85 s from firing:&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;S=&amp;lt;scratch&amp;gt; uv run poe stop-deps 2&amp;gt;&amp;amp;1 | tail -2; pgrep -fl 'python main.py|te...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ S=&amp;lt;scratch&amp;gt; uv run poe stop-deps 2&amp;gt;&amp;amp;1 | tail -2; pgrep -fl 'python main.py|temporal server|ollama serve' || echo "everything down at $(date +%H:%M:%S), before t
… [46 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;ollama: stopped (pid 60105)
temporal: stopped (pid 60123)
everything down at 02:57:25, before the 02:57:21 due time&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;S=&amp;lt;scratch&amp;gt; while [ "$(date +%H%M%S)" \&amp;lt; "025830" ]; do sleep 2; done; echo "...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ S=&amp;lt;scratch&amp;gt; while [ "$(date +%H%M%S)" \&amp;lt; "025830" ]; do sleep 2; done; echo "restart at $(date +%H:%M:%S)" | tee -a $S/kr-times.txt; : &amp;gt; $S/up.log; python3 $S/l
… [924 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;(eval):1: condition expected: &amp;lt;
restart at 02:57:40
temporal: started on 127.0.0.1:7233, UI at http://127.0.0.1:8233 (pid 78752)
2026-10-04 02:57:44,765 INFO pa
… [252 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;temporal workflow show -w r-&amp;lt;id&amp;gt; --address 127.0.0.1:7233 -o json 2&amp;gt;/dev/null...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ temporal workflow show -w r-&amp;lt;id&amp;gt; --address 127.0.0.1:7233 -o json 2&amp;gt;/dev/null | python3 -c '
import json,sys
from datetime import datetime, timedelta
d=json.loa
… [1052 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;3:02:55:35.294 activity read_input
6:02:55:35.330 activity discard_input
9:02:55:36.108 activity show_confirm
12:02:55:36.119 timer started 604800s
13:02:55:38.
… [471 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;It's poe itself; uv isn't involved. Reading poe's shutdown logic to see what escalates:&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;sed -n '1,60p;135,200p' .venv/lib/python3.12/site-packages/poethepoet/shutdow...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ sed -n '1,60p;135,200p' .venv/lib/python3.12/site-packages/poethepoet/shutdown.py | grep -v '^\s*$'&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;1:from __future__ import annotations
3:import asyncio
4:import contextlib
5:import os
6:import signal
7:import subprocess
8:import sys
9:from typing import TYPE
… [4257 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;S=&amp;lt;scratch&amp;gt; R=.; cd $S/sigtest &amp;amp;&amp;amp; python3 - &amp;lt;&amp;lt;'EOF'
import re, pathlib
p = pa...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ S=&amp;lt;scratch&amp;gt; R=.; cd $S/sigtest &amp;amp;&amp;amp; python3 - &amp;lt;&amp;lt;'EOF'
import re, pathlib
p = pathlib.Path("pyproject.toml"); s = p.read_text()
new_up = """[tool.poe.tasks.up]
cmd
… [521 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;run 1 (uv run poe up, Ctrl-C to the group)
  Poe =&amp;gt; bash -c 'set -uo pipefail
  # poe hands this process over (use_exec), so nothing SIGKILLs a slow shutdown.
 
… [1403 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Before testing the fix on the real stack, which means restarting it, I'll check whether you've already tapped Snooze/Paid on the pending reminder:&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;temporal workflow show -w r-&amp;lt;id&amp;gt; --address 127.0.0.1:7233 -o json 2&amp;gt;/dev/null...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ temporal workflow show -w r-&amp;lt;id&amp;gt; --address 127.0.0.1:7233 -o json 2&amp;gt;/dev/null | python3 -c '
import json,sys
from datetime import datetime, timedelta
d=json.loa
… [848 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;02:55:38 SIGNAL save
02:57:45 activity send_reminder
02:59:22 activity send_reminder
now 03:01:16&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;S=&amp;lt;scratch&amp;gt;
stop_old() { kill -INT -- -$(cat $S/up.pgid) 2&amp;gt;/dev/null; for _ i...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ S=&amp;lt;scratch&amp;gt;
stop_old() { kill -INT -- -$(cat $S/up.pgid) 2&amp;gt;/dev/null; for _ in $(seq 20); do pgrep -f 'python main.py' &amp;gt;/dev/null || break; sleep
… [961 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;1. old stack fully down
2026-10-04 03:01:38,383 INFO paidyet: Telegram: polling as @&amp;lt;bot&amp;gt;
2026-10-04 03:01:38,383 INFO paidyet: PaidYet is up. Ctrl-C to stop.
2
… [397 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;cat &amp;gt;&amp;gt; docs/BUILD_LOG.md &amp;lt;&amp;lt;'EOF'

## Sun 4 Oct, ~02:30–03:05: live testing (r...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ cat &amp;gt;&amp;gt; docs/BUILD_LOG.md &amp;lt;&amp;lt;'EOF'

## Sun 4 Oct, ~02:30–03:05: live testing (real Telegram, Sentry, Temporal, Gemma)

**Integration tests: 5/5 pass** against the
… [4142 more characters trimmed]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;============================= 137 passed in 6.49s ==============================
652eb61 poe up: hand the process over so Ctrl-C can't SIGKILL the cleanup&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
  &lt;/div&gt;

  &lt;div class="agent-session-footer"&gt;
    &lt;span class="agent-session-meta"&gt;
        36 of 36 messages
    &lt;/span&gt;
  &lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma:&lt;/strong&gt; Gemma 4 (e4b) runs locally in Ollama and reads bill photos and Hinglish IOUs through structured output. Code checks every answer and asks Gemma again, once, when it spots a known mistake.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Temporal:&lt;/strong&gt; One durable workflow per due. The Gemma read is a retried activity and the input is deleted in a &lt;code&gt;finally&lt;/code&gt;. Save, Fix, Paid and Snooze are signals, status is a query, and reminders survive a full stop and restart (24.6 s late, not lost).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Sentry Agent Tracing:&lt;/strong&gt; &lt;code&gt;invoke_agent&lt;/code&gt;, &lt;code&gt;gen_ai.chat&lt;/code&gt; and &lt;code&gt;execute_tool&lt;/code&gt; spans carry tokens and Ollama's load, prompt and generation timings, in one trace from Telegram through Temporal into every activity, with no bill contents in Sentry, enforced by a test.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>hf26challenge</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Same Prompt, Same Diagram — Teaching Agents to Draw on a Local Canvas</title>
      <dc:creator>Prateek Rai</dc:creator>
      <pubDate>Sun, 26 Jul 2026 18:55:47 +0000</pubDate>
      <link>https://dev.to/prateek11rai/same-prompt-same-diagram-teaching-agents-to-draw-on-a-local-canvas-1ib</link>
      <guid>https://dev.to/prateek11rai/same-prompt-same-diagram-teaching-agents-to-draw-on-a-local-canvas-1ib</guid>
      <description>&lt;p&gt;At work I keep producing engineering diagrams and the occasional deck. They end up in whatever tool is closest — a whiteboard app here, a slides app there — never consistent, never durable, always one-shot.&lt;/p&gt;

&lt;p&gt;I wanted two things: a tool that runs locally, and output that looks the same every single time. So I spent two days teaching coding agents to drive a canvas app, and shipped it as &lt;a href="https://github.com/prateek11rai/tldraw-canvas-kit" rel="noopener noreferrer"&gt;tldraw-canvas-kit&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftw2yoi8kvabhgnwtx2en.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftw2yoi8kvabhgnwtx2en.jpg" alt="The showcase deck built by the kit — title slide with its own PowerPoint-style filmstrip rail" width="768" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Is
&lt;/h2&gt;

&lt;p&gt;tldraw-canvas-kit is a set of 19 &lt;a href="https://skills.sh/prateek11rai/tldraw-canvas-kit" rel="noopener noreferrer"&gt;Agent Skills&lt;/a&gt; that let coding agents — Claude Code, Cursor, Codex, anything the skills CLI supports — drive the free &lt;a href="https://offline.tldraw.com" rel="noopener noreferrer"&gt;tldraw offline desktop app&lt;/a&gt; through its local API. Thirteen diagram types (sequence, flowchart, ER, state, class, activity swimlanes, use-case, component, package, deployment, architecture, mindmap, and a codebase map that reverse-engineers a repo), plus presentations, whitepapers, interactive widgets, export, and theming.&lt;/p&gt;

&lt;p&gt;You describe intent; the skill owns the structure. That division is the whole point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why tldraw Offline
&lt;/h2&gt;

&lt;p&gt;The offline app was the unlock. It runs a local HTTP server — a &lt;code&gt;server.json&lt;/code&gt; with a port and a per-launch bearer token — so an agent on the same machine can search documents, execute code against a live editor, and install durable scripts into a document. No cloud, no account, no telemetry between me and my diagrams.&lt;/p&gt;

&lt;p&gt;Documents are plain &lt;code&gt;.tldraw&lt;/code&gt; files. And because the app ships its own SDK, scripts bundled into a document need no license key — the behavior travels &lt;em&gt;inside the file&lt;/em&gt;. Send someone the deck and the filmstrip navigation comes with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Determinism Is the Product
&lt;/h2&gt;

&lt;p&gt;LLMs already know what a sequence diagram is. Ask twice, though, and you get two different layouts, two different arrow styles, two different everythings. What agents lack isn't knowledge — it's consistency.&lt;/p&gt;

&lt;p&gt;So each skill is a fixed structure spec: layout math, stencils, stable id schemes, &lt;code&gt;meta&lt;/code&gt; tagging on every shape the kit creates. The same request produces the same canvas every run.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxg2p6fy6nwutawnflfio.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxg2p6fy6nwutawnflfio.jpg" alt="A sequence diagram built to spec, living inside a slide of the deck" width="768" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Idempotency is load-bearing. Re-running a skill against an existing diagram updates shapes in place, prunes anything the spec dropped, and reconciles edges whose spec changed. That last one was a hard-won bug: my first pass skipped edges whose key already existed, which meant an edge's style could &lt;em&gt;never&lt;/em&gt; be corrected. The fix — store the full spec in the edge's meta and delete-recreate on mismatch — is now a rule every diagram skill carries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reality Edits the Spec
&lt;/h2&gt;

&lt;p&gt;Every skill was verified against the live app, and the specs got corrected by reality over and over. My three favorite findings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Arrows silently ignore custom dash styles and default to sketchy.&lt;/strong&gt; You have to create the arrow, then update it. No error, no warning — your crisp architecture diagram just quietly comes out hand-drawn.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Arrows draw straight through intervening shapes, and no lint catches it.&lt;/strong&gt; The document validates perfectly while an edge slices through a node's label. Screenshot review became a mandatory verification step because the data model literally cannot see this class of defect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge labels wrap mid-word unless the arrow has roughly 30px of length per character.&lt;/strong&gt; "deliver" becoming "deliv‑er" across two lines is the kind of thing you only learn by looking.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern across all of them: every failed test became a permanent rule in the skill docs. Test artifacts turned into specifications. The skills aren't what I guessed the API does — they're what the API demonstrably does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Demo Is the Test Suite
&lt;/h2&gt;

&lt;p&gt;The showcase deck has one slide per skill — 23 slides with real mini-diagrams living &lt;em&gt;inside&lt;/em&gt; the slides: sequence, ER, state machines, three-level-nested deployment zones, a codebase map of the kit's own repo.&lt;/p&gt;

&lt;p&gt;It totals 203 shapes and 45 bound edges with zero lint findings, and the builder is deterministic enough that it produced byte-identical counts in two separate documents. So I shipped it as the integration test: &lt;code&gt;tests/golden-deck/run.sh&lt;/code&gt; rebuilds the deck and asserts exact shape counts, idempotent re-runs, and navigation interactions.&lt;/p&gt;

&lt;p&gt;The README gallery and every screenshot in this post are the test output. If the images look right, the test passed.&lt;/p&gt;

&lt;h2&gt;
  
  
  There's React in My Canvas
&lt;/h2&gt;

&lt;p&gt;The best discovery of the project: document scripts can import exactly three modules — &lt;code&gt;tldraw&lt;/code&gt;, &lt;code&gt;react&lt;/code&gt;, and &lt;code&gt;react-dom&lt;/code&gt;. Which means custom shape types that render real, clickable HTML on the canvas.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgd0r8wywp3koxjz7wfs3.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgd0r8wywp3koxjz7wfs3.jpg" alt="The interactive slide — an SVG world map with a pulsing region pin, plus working buttons and a claps counter" width="768" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The deck's interactive slide has a low-poly SVG world map with a graticule and a pulsing region pin driven by a shape prop, next to a claps counter. Actual React components, living inside a &lt;code&gt;.tldraw&lt;/code&gt; file, surviving save and reopen.&lt;/p&gt;

&lt;p&gt;The gotcha that cost me the most time: render closures coalesce rapid clicks. Click a counter five times fast and it increments once, because each handler captured the same stale value. The rule — handlers read fresh store state, never their closure — is now in the widget skill's contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Widgets Are Researched, Not Canned
&lt;/h2&gt;

&lt;p&gt;I surveyed the agent-skills ecosystem while building this, and one gap stood out: skills either ship canned components or nothing. The kit does neither. It ships &lt;em&gt;contracts&lt;/em&gt; plus a research doctrine — check the live API first, then domain-authority references (tldraw.dev examples, the D3 gallery, MDN), then port into the kit's conventions.&lt;/p&gt;

&lt;p&gt;We had a hand-rolled wireframe globe early on. It got deleted from the repo for violating exactly this doctrine — replaced by a researched, location-aware map built at request time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F18se2odvopds0nrz6owd.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F18se2odvopds0nrz6owd.jpg" alt="A screenshot of the deck imported as an image asset into the deck itself — assets bundle inside the portable file" width="768" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The same conventions extend to media: imported screenshots become assets bundled inside the &lt;code&gt;.tldraw&lt;/code&gt; zip, fit-to-box so they never overflow their slide, and each presentation lives in its own folder with its assets and exports beside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shipping It
&lt;/h2&gt;

&lt;p&gt;The kit installs two ways: through the skills CLI for 70+ agents, or as a Claude Code plugin with a bundled canvas-operator subagent. Every skill is vendored self-contained, CI checks sync and discovery, commits are signed, and the golden deck is the regression gate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx skills add prateek11rai/tldraw-canvas-kit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Links: the &lt;a href="https://github.com/prateek11rai/tldraw-canvas-kit" rel="noopener noreferrer"&gt;repo&lt;/a&gt;, the &lt;a href="https://github.com/prateek11rai/tldraw-canvas-kit/releases/tag/v0.1.0" rel="noopener noreferrer"&gt;v0.1.0 release&lt;/a&gt;, and the &lt;a href="https://skills.sh/prateek11rai/tldraw-canvas-kit" rel="noopener noreferrer"&gt;skills.sh page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Honest limits: macOS and Linux only for now (Windows untested — contributions welcome), and the offline app's API is new and unofficial, so things may shift underneath.&lt;/p&gt;

&lt;h2&gt;
  
  
  Built for Me
&lt;/h2&gt;

&lt;p&gt;When I &lt;a href="https://x.com/Prateek11Rai/status/2081443529944109085" rel="noopener noreferrer"&gt;posted about it&lt;/a&gt;, I closed with: pretty sure very few people will use this. It's for me.&lt;/p&gt;

&lt;p&gt;That's still the honest framing. I wanted my diagrams to stop moving around between sessions, and now they don't. If a local canvas that agents can drive deterministically is your itch too — it's one &lt;code&gt;npx&lt;/code&gt; away.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7iu9jtqmzludg39aqbgp.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7iu9jtqmzludg39aqbgp.jpg" alt="The launch tweet — the interactive-canvas slide as its image" width="581" height="561"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;tldraw-canvas-kit is an unofficial project, not affiliated with tldraw Inc. The offline app and its local API are theirs, and both are excellent — all credit to the tldraw team.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>tldraw</category>
      <category>agentskills</category>
    </item>
    <item>
      <title>Building a Vitals Monitor for Claude Code</title>
      <dc:creator>Prateek Rai</dc:creator>
      <pubDate>Sat, 18 Jul 2026 23:23:02 +0000</pubDate>
      <link>https://dev.to/prateek11rai/building-a-vitals-monitor-for-claude-code-3ndh</link>
      <guid>https://dev.to/prateek11rai/building-a-vitals-monitor-for-claude-code-3ndh</guid>
      <description>&lt;p&gt;A Slack thread about our climbing AI bill, plus word that per-user usage limits were landing soon, sent me to check what I was actually spending on Claude Code. The answer was that I had no idea.&lt;/p&gt;

&lt;p&gt;The cost only showed up after the fact, in a dashboard I never opened. So I built a gauge instead. My terminal footer now reads out the session's vitals on every render, and the whole thing is one shell script.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg5on6au5o922n4xnzh9o.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg5on6au5o922n4xnzh9o.jpg" alt="Luffy raises his fist in the snow outside Drum Castle, inviting Tony Tony Chopper (the little reindeer doctor, at bottom right) to join the Straw Hat crew" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In a hurry? Skip to what the line shows.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the status line gets to see
&lt;/h2&gt;

&lt;p&gt;Claude Code pipes a JSON blob to whatever you register as its &lt;code&gt;statusLine&lt;/code&gt; command, once on every render. My script reads that blob with a single &lt;code&gt;jq&lt;/code&gt; pass and prints one line back. Claude Code renders each stdout line as its own status row, so I get to decide exactly what sits under the prompt.&lt;/p&gt;

&lt;p&gt;Registering it is two lines in &lt;code&gt;~/.claude/settings.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"statusLine"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bash ~/.claude/statusline-command.sh"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the field extraction, trimmed to what matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# One jq pass over the JSON Claude Code pipes in on every render.&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'[
  (.model.display_name                       // ""),
  (.context_window.used_percentage           // "" | tostring),
  (.context_window.total_input_tokens        // "" | tostring),
  (.context_window.context_window_size       // "" | tostring),
  (.cost.total_cost_usd                      // "" | tostring),
  (.rate_limits.five_hour.used_percentage    // "" | tostring),
  (.rate_limits.seven_day.used_percentage    // "" | tostring),
  (.exceeds_200k_tokens                      // false | tostring),
  (.effort.level                             // "" | tostring)
] | join("\u001f")'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;\u001f&lt;/code&gt; is a unit separator, not a tab. Tab is IFS whitespace, so &lt;code&gt;read&lt;/code&gt; would collapse the empty fields and shift every value one slot to the left. The rate-limit and usage fields are absent early in a session, so empty fields are the normal case, not the edge case. Learned that one the hard way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I watch, and why each segment earns its place
&lt;/h2&gt;

&lt;p&gt;Every segment has to justify the pixels. Here is the full line and what each part answers.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Segment&lt;/th&gt;
&lt;th&gt;Reads from&lt;/th&gt;
&lt;th&gt;Why it's on screen&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Opus 4.8 (1M context)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;model.display_name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Which model and window I'm on, without guessing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;xhigh&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;effort.level&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;High effort burns output tokens fast; seeing it nudges me to dial down for cheap tasks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;▓▓░░░ 34% 343k/1M&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;context_window.*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How full the window is. Near the top means auto-compaction is close, and per-turn cost is climbing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⚠&amp;gt;200K&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;exceeds_200k_tokens&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;This request's input crossed 200K tokens (see below)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⚡99%&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;context_window.current_usage.*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Share of input served from cheap cache reads instead of full-price fresh input&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;$18.20&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cost.total_cost_usd&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Dollars this session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;5h:25%↻2h33m 7d:10%↻3d13h&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;rate_limits.*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Rolling rate-limit usage and time until each window resets&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The meter is a five-slot bar that goes green under 50%, yellow through 79%, red at 80% and up. The cache indicator is computed, not read: &lt;code&gt;cache_read / (input + cache_creation + cache_read)&lt;/code&gt;, colored the same way. A low number there means I'm paying full input price every turn, which is real money on a long session.&lt;/p&gt;

&lt;p&gt;In a narrow tmux split the line wraps to two rows and starts shedding segments in a fixed order: effort, then limits, then cache, then the token fraction, then cost. The &lt;code&gt;⚠&amp;gt;200K&lt;/code&gt; warning is never dropped. It's the one thing I never want to lose sight of, for reasons in the next section.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two costs that surprise people
&lt;/h2&gt;

&lt;p&gt;There are two different cost mechanisms in play, and the line treats them as separate things because they are.&lt;/p&gt;

&lt;p&gt;The first is the long-context tier. The &lt;code&gt;⚠&amp;gt;200K&lt;/code&gt; flag comes straight from the &lt;code&gt;exceeds_200k_tokens&lt;/code&gt; boolean, and it lights up when a single request's input crosses 200K tokens. On the models that carry a long-context pricing tier, that's the point where input past 200K starts billing at a higher per-token rate. The honest caveat: at the time of writing, the current Opus (Opus 4.8, 1M context) bills the whole window at standard rates with no long-context premium. So treat the flag as "you're in long-context territory, go check what your model charges here," not a guaranteed price jump. &lt;a href="https://www.anthropic.com/pricing" rel="noopener noreferrer"&gt;Anthropic's pricing&lt;/a&gt; is the source of truth, and these tiers move.&lt;/p&gt;

&lt;p&gt;The second is plan-level "extra usage," and it's what the 5h/7d segments are really about. Those track rolling usage against the plan's rate limits. On Team plans with spillover enabled, usage past the included allotment bills at API rates, roughly 4–5× the effective included rate at the time of writing. The status line can't show a dollar figure for it, because there is no field for it in the JSON. The percentages and reset countdowns are the proxy: &lt;code&gt;5h:25%↻2h33m&lt;/code&gt; means I'm a quarter into the five-hour window with 2h33m until it resets.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Hot Take&lt;/strong&gt;&lt;br&gt;
Cost is the number everyone watches and it's the wrong one to watch alone. Crossing the 200K threshold and burning down a rate-limit window are leading indicators; the dollar figure is the lagging one that arrives after the decision that caused it. The reset countdown turned rate limits from an invisible ceiling into something I can pace against.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Reading the line at a glance
&lt;/h2&gt;

&lt;p&gt;Here it is in the wild: a minimal WezTerm window, tmux doing the multiplexing, and the footer carrying the whole story.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1zl25ute070o7ux3b0h7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1zl25ute070o7ux3b0h7.png" alt="WezTerm running a tmux session with the custom Claude Code status line across the bottom, showing model, context meter, the 200K flag, cache-hit rate, session cost, and rate-limit resets" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Bottom row, left to right: identity and branch, then &lt;code&gt;Opus 4.8 (1M context)&lt;/code&gt;, &lt;code&gt;xhigh&lt;/code&gt;, the context meter at &lt;code&gt;34% 343k/1M&lt;/code&gt;, &lt;code&gt;⚠&amp;gt;200K&lt;/code&gt; lit (343k is well past the threshold), &lt;code&gt;⚡99%&lt;/code&gt; cache hits, &lt;code&gt;$18.20&lt;/code&gt; spent, and the two reset clocks. One glance and I know where the session stands before I fire off the next expensive turn.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I kept my own script
&lt;/h2&gt;

&lt;p&gt;Before committing to this, I compared it against &lt;code&gt;ccstatusline&lt;/code&gt;, the popular off-the-shelf option. The deciding factor was where the data lives.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;statusLine&lt;/code&gt; JSON hands you almost everything worth showing for free: model, effort, context, cost, cache, rate limits. The one thing it has no field for is an extra-usage or overage dollar figure — that lives behind a separate Usage API, which is exactly what &lt;code&gt;ccstatusline&lt;/code&gt; calls to fill the gap. I didn't want the dollar figure badly enough to take on a second data source and its auth, so I kept the script and just added the fields it was missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  rtk, the token saver that briefly lied to me
&lt;/h2&gt;

&lt;p&gt;Separate from the status line, but part of the same "spend the context window on thinking, not scrollback" goal, I run &lt;code&gt;rtk&lt;/code&gt; (Rust Token Killer). It's an output-compression proxy wired in as a Claude Code PreToolUse Bash hook (&lt;code&gt;rtk hook claude&lt;/code&gt;). It squeezes the command output that flows back into the model's context — long &lt;code&gt;git log&lt;/code&gt;, directory listings, build dumps — so scrollback doesn't eat the window.&lt;/p&gt;

&lt;p&gt;It works. It also bit me this session. &lt;code&gt;rtk&lt;/code&gt; was aggressive enough to collapse my own diagnostic output: a &lt;code&gt;wc -l&lt;/code&gt; came back &lt;code&gt;0&lt;/code&gt;, an &lt;code&gt;ls -la&lt;/code&gt; came back &lt;code&gt;(empty)&lt;/code&gt;, and for a minute I was convinced real commands were failing. They weren't. The hook had compressed the output to nothing before I ever saw it. The fix was to redirect to a file and read the file directly, bypassing the hook entirely.&lt;/p&gt;

&lt;p&gt;You can catch it happening in the screenshot above, where a &lt;code&gt;cd dotfiles&lt;/code&gt; reads &lt;code&gt;Bash completed with no output&lt;/code&gt;. A token-saver that hides real output is a trade-off, and I'd rather say that plainly than pretend it's free.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the pieces fit
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNpFkMtKxEAQRX-lqJVCooPLILOJYUCjglEQjIsyXXlIP2I_xGFm_t2aCWhvGm7fc4rqHXZOMRY4eJpHqJ9aC3LK8q3FUlNSDKUUrj_85fqMv9lvwbNV7M9bfIc8X-9DpJhCPVmG2-bxYQ9NLeySaknzzhlDVl2EUZBF39Qn1Anz-QUzhSBYtTlyPBi2MRRgZKzOwHBkn53mX61Wd9BrGjLoqBtZLhdiBnoyUwz_8mpztMPz_curGKNJP9A7JxqpYIaGvaFJyc47eR3ZcIsFtGg5RU-6xYOU0qwocqWm6DwWPenAGVKKrtna7i9YWjcTye8ZLKJPfPgFKtRwJA" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNpFkMtKxEAQRX-lqJVCooPLILOJYUCjglEQjIsyXXlIP2I_xGFm_t2aCWhvGm7fc4rqHXZOMRY4eJpHqJ9aC3LK8q3FUlNSDKUUrj_85fqMv9lvwbNV7M9bfIc8X-9DpJhCPVmG2-bxYQ9NLeySaknzzhlDVl2EUZBF39Qn1Anz-QUzhSBYtTlyPBi2MRRgZKzOwHBkn53mX61Wd9BrGjLoqBtZLhdiBnoyUwz_8mpztMPz_curGKNJP9A7JxqpYIaGvaFJyc47eR3ZcIsFtGg5RU-6xYOU0qwocqWm6DwWPenAGVKKrtna7i9YWjcTye8ZLKJPfPgFKtRwJA" alt="Architecture diagram" width="1153" height="118"&gt;&lt;/a&gt;&lt;br&gt;
Claude Code renders, pipes the JSON, the script parses it once and prints, tmux shows it. No daemon, no polling, no state. It recomputes from scratch on every render, so what you see is always the current session, with no cached numbers to go stale.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it changed
&lt;/h2&gt;

&lt;p&gt;The line changed how I work more than I expected. I match effort to the task now instead of leaving &lt;code&gt;xhigh&lt;/code&gt; on for a one-line edit. I keep the prompt prefix stable so the cache-hit rate stays high. I pace long runs against the reset clock instead of running into a wall I couldn't see. None of that is new information — it was all available before, in a dashboard, after the fact. Putting it under the cursor is what made me act on it.&lt;/p&gt;

&lt;p&gt;The script and its install live in my &lt;a href="https://github.com/prateek11rai/dotfiles" rel="noopener noreferrer"&gt;dotfiles&lt;/a&gt;, symlinked and set up by the bootstrap only when the &lt;code&gt;claude&lt;/code&gt; CLI is present.&lt;/p&gt;




&lt;p&gt;Chopper is the Straw Hats' doctor. He doesn't wait for a crewmate to drop mid-fight; he watches their vitals and calls it early. That's what this footer is — a vitals monitor for the session, taking its pulse on every render. And when the window gets tight there's a bit of Sanji in it too: rationing a limited pantry so nothing goes to waste, which is the entire job of &lt;code&gt;rtk&lt;/code&gt; and, fittingly, the name over the door of this blog.&lt;/p&gt;

&lt;p&gt;Chopper only became that doctor because Dr. Hiriluk, the quack everyone laughed at, believed in him first.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F63h3nowpoct2cc4whros.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F63h3nowpoct2cc4whros.jpg" alt="Dr. Hiriluk, tears streaming down his face, hugging a young Chopper on snowy Drum Island — the doctor who first believed in him" width="704" height="528"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;Intro and closing images are from the One Piece anime via the &lt;a href="https://onepiece.fandom.com/" rel="noopener noreferrer"&gt;One Piece Wiki&lt;/a&gt;: the &lt;a href="https://onepiece.fandom.com/wiki/File:Luffy_Tells_Chopper_to_Join.png" rel="noopener noreferrer"&gt;intro scene of Luffy inviting Chopper to join&lt;/a&gt; and the closing scene of &lt;a href="https://onepiece.fandom.com/wiki/File:Hiriluk_Hugs_Chopper.png" rel="noopener noreferrer"&gt;Dr. Hiriluk and Chopper&lt;/a&gt;. Attribution and credits — please &lt;a href="mailto:prateek11rai@protonmail.com"&gt;contact&lt;/a&gt; if anything needs updating.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>terminal</category>
      <category>tokens</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>How Python's rglob Silently Loses Files, and Why macOS Makes It Worse</title>
      <dc:creator>Prateek Rai</dc:creator>
      <pubDate>Sat, 04 Jul 2026 12:31:26 +0000</pubDate>
      <link>https://dev.to/prateek11rai/how-pythons-rglob-silently-loses-files-and-why-macos-makes-it-worse-58ap</link>
      <guid>https://dev.to/prateek11rai/how-pythons-rglob-silently-loses-files-and-why-macos-makes-it-worse-58ap</guid>
      <description>&lt;p&gt;A Python service I work on did something that shouldn't be possible. It wrote a directory full of files, turned around to upload them, and found nothing. The files were right there — &lt;code&gt;ls&lt;/code&gt; saw them from another terminal.&lt;/p&gt;

&lt;p&gt;The upload logged success. Zero files. No exception. Digging into why took me through three separate layers of the stack — none of which would tell me what actually happened — and a paper trail going back to 2015 that almost nobody has read.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzrq4cppu4kjtps4m7c95.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzrq4cppu4kjtps4m7c95.jpg" alt="Nico Robin standing before the massive red Road Poneglyph inside the Whale Tree of Zou, dwarfed by a wall of ancient glyphs" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The record of every one of these problems has been sitting in public bug trackers and mailing lists for years — some of it for two decades. This post is the story of the bug, the archaeology of who reported what and when, and the fix we shipped while the deeper layers stay unfixed.&lt;/p&gt;

&lt;p&gt;If this corner of the stack is new to you, here's the short version of each layer, in the order the post walks them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Python's &lt;code&gt;rglob&lt;/code&gt; hides errors.&lt;/strong&gt; It walks a folder tree and returns every file it finds — and if the operating system refuses or fails anywhere along the walk, it silently skips instead of complaining. A folder it couldn't read looks exactly like a folder with nothing in it, and there is no way to find out which one you got.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The operating system never promised your listing was fresh.&lt;/strong&gt; A listing that races concurrent writes is allowed to miss the newest files — on every system — and on macOS the filesystem adds behaviour Apple has never documented in either direction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The obvious repair tool doesn't do what its name says.&lt;/strong&gt; &lt;code&gt;fsync&lt;/code&gt;, the call that's supposed to force pending writes all the way down to disk, only goes halfway on macOS — and it was never designed to freshen directory listings in the first place.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each section below expands one layer, with receipts. When the jargon gets thick, look for the collapsible "in plain English" boxes — they're there so you can keep up without leaving the page. And if you're here because you have this bug &lt;em&gt;right now&lt;/em&gt;, jump straight to the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Program That Succeeds at Doing Nothing
&lt;/h2&gt;

&lt;p&gt;The service writes a batch of parquet files (the columnar data files pipelines pass around) into a directory, then hands that directory to an uploader. The uploader does what every Python tutorial would tell you to do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;discovered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rglob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_file&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;discovered&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;uploaded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;discovered&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;skipped:nothing_to_upload&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pathlib&lt;/code&gt; is the modern, blessed way to walk a directory. On a quiet machine this always works. In production — Linux containers on ARM, like almost everyone's production — it intermittently returned an empty iterator for a directory that had files in it. An &lt;code&gt;os.listdir&lt;/code&gt; in the same process, microseconds later, saw them fine.&lt;/p&gt;

&lt;p&gt;The insidious part is what happens next: nothing. The caller sees an empty list and proceeds. Logs say the upload completed. The next pipeline stage treats the empty result as a legitimate zero-row dataset. A program that crashes gets fixed the same day; a program that silently does nothing can lie to you for months.&lt;/p&gt;

&lt;p&gt;So I did the obvious thing and tried to reproduce it locally — on an Apple Silicon MacBook, because that's what dev laptops are now. It reproduced. Then it kept reproducing in ways production had never shown: wider windows, weirder timings, listings that disagreed with each other from one call to the next. The repro environment was lying in more ways than the production one.&lt;/p&gt;

&lt;p&gt;It turned out three independently-true facts were involved. The first fires on every platform and is the heart of the production bug. The second is allowed everywhere but gets dramatically worse on a Mac. The third is purely a macOS problem — and it matters because a Mac is where you'll be standing when you try to debug this. None of the three is a bug in the villain sense. All of them are documented — if you know where to dig.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer One — pathlib Swallows Errors, and Has Since 2015
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;rglob&lt;/code&gt; works by walking into a folder, asking the operating system what's inside, and repeating that for every subfolder it finds. At any step of that walk, the OS can say no: you don't have permission, the thing it was reading vanished a moment ago, a read transiently failed. In Python, those refusals arrive as an &lt;code&gt;OSError&lt;/code&gt; exception.&lt;/p&gt;

&lt;p&gt;Here's the design decision at the heart of this post: when that happens inside &lt;code&gt;glob()&lt;/code&gt; or &lt;code&gt;rglob()&lt;/code&gt;, pathlib catches the error and just keeps going. No exception, no warning, no hook to find out it happened. Whatever it was scanning at that moment quietly drops out of the results. So the output of &lt;code&gt;rglob&lt;/code&gt; cannot distinguish "this directory is empty" from "this directory has a thousand files I wasn't allowed to see."&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  In plain English — what 'swallowing an error' looks like
  &lt;p&gt;Deep inside pathlib's directory walk sits the moral equivalent of this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;entries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scandir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;directory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# ask the OS what's inside
&lt;/span&gt;&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;OSError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;pass&lt;/span&gt;                              &lt;span class="c1"&gt;# couldn't read it? pretend it was empty
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the read fails, pretend it succeeded and found nothing. There's no log, no flag, no exception for the caller to catch — a failed read and a genuinely empty folder produce the &lt;em&gt;identical&lt;/em&gt; return value. That's the property this whole post hangs on: not just that errors are dropped, but that their absence is unobservable.&lt;/p&gt;



&lt;br&gt;
&lt;p&gt;&lt;/p&gt;

&lt;p&gt;The paper trail on this is remarkable, and nobody has ever written it up as a story. It starts in May 2015, when a bug tracker user named Gregorio filed &lt;a href="https://github.com/python/cpython/issues/68308" rel="noopener noreferrer"&gt;cpython#68308&lt;/a&gt; — asking, reasonably, that &lt;code&gt;rglob&lt;/code&gt; &lt;em&gt;stop crashing&lt;/em&gt; on unreadable directories and skip them the way &lt;code&gt;os.walk&lt;/code&gt; does. Core developer Serhiy Storchaka was the lone dissent, proposing a bash-&lt;code&gt;failglob&lt;/code&gt;-style option to surface errors instead. That idea was deflected to "a different issue" which was never filed.&lt;/p&gt;

&lt;p&gt;The suppression patch (by Ulrich Petri) sat unreviewed for months, until Guido van Rossum personally hit the bug in January 2016: "I'm just going to commit this." Two days later he noticed his own fix made results depend on directory scan order — "dependent on the ordering of the names returned by listdir(), which is just wrong" — re-patched it, and closed with a line that reads differently a decade later: "Should be fixed for real now."&lt;/p&gt;

&lt;p&gt;It wasn't. In 2019, Thierry Parmentelat filed &lt;a href="https://github.com/python/cpython/issues/83075" rel="noopener noreferrer"&gt;cpython#83075&lt;/a&gt; with &lt;code&gt;strace&lt;/code&gt; output (a tool that records every call a program makes to the OS) showing that a single symlink into an unreadable directory made &lt;code&gt;glob()&lt;/code&gt; silently drop &lt;em&gt;other, perfectly readable&lt;/em&gt; files. Matt Wozniski reduced it to a five-command repro, and Pablo Galindo root-caused it as a regression from a performance rewrite. Nobody had noticed — because the failure mode is silence, exactly as Guido's warning predicted.&lt;/p&gt;

&lt;p&gt;The endgame is the telling part. In 2021, &lt;a href="https://github.com/python/cpython/issues/90208" rel="noopener noreferrer"&gt;cpython#90208&lt;/a&gt; asked for an opt-in flag to surface the errors. It was resolved in 2023 by pathlib's maintainer Barney Gale going the &lt;em&gt;other&lt;/em&gt; way: suppressing &lt;strong&gt;all&lt;/strong&gt; &lt;code&gt;OSError&lt;/code&gt; below the top-level path. That shipped in Python 3.13, whose docs now state that errors are "suppressed in many cases, but not all." And in March 2026, Jakub Kuczys demonstrated that &lt;code&gt;list(Path('/root').glob('*'))&lt;/code&gt; returns &lt;code&gt;[]&lt;/code&gt; with zero indication anything went wrong — and filed &lt;a href="https://github.com/python/cpython/issues/146646" rel="noopener noreferrer"&gt;cpython#146646&lt;/a&gt; as a &lt;em&gt;docs-only&lt;/em&gt; issue, writing: "Based on #68308 (and just the fact that it has stayed unchanged for years), I assume this is intentional." Eleven years in, the behaviour's age had become the argument for keeping it.&lt;/p&gt;

&lt;p&gt;What makes this sting is that CPython knows how to do better — it just didn't do it here. &lt;code&gt;os.walk&lt;/code&gt; has taken an &lt;code&gt;onerror&lt;/code&gt; callback since forever. &lt;code&gt;Path.walk&lt;/code&gt;, added in 3.12, takes &lt;code&gt;on_error&lt;/code&gt;. POSIX &lt;code&gt;find&lt;/code&gt; prints to stderr and exits nonzero when it can't read a directory. &lt;code&gt;glob&lt;/code&gt; and &lt;code&gt;rglob&lt;/code&gt; give you nothing. "Errors should never pass silently. Unless explicitly silenced" is in the Zen of Python; there is no way to explicitly un-silence these.&lt;/p&gt;

&lt;p&gt;The most rigorous Python codebases already know. Black recurses with &lt;code&gt;iterdir&lt;/code&gt; and handles errors itself. pytest's internal &lt;code&gt;visit()&lt;/code&gt; helper walks with &lt;code&gt;os.scandir&lt;/code&gt;. mypy's file cache catches &lt;code&gt;OSError&lt;/code&gt; and &lt;em&gt;re-raises&lt;/em&gt; it. Ruff walks the tree in Rust.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Trivia&lt;/strong&gt;&lt;br&gt;
Ruff has a lint rule, &lt;a href="https://docs.astral.sh/ruff/rules/glob/" rel="noopener noreferrer"&gt;PTH207&lt;/a&gt;, that tells you to replace &lt;code&gt;glob.glob&lt;/code&gt; with &lt;code&gt;Path.glob&lt;/code&gt; because pathlib is more idiomatic. The migration it recommends moves you from an API that suppresses &lt;em&gt;most&lt;/em&gt; errors to one that, since 3.13, suppresses &lt;em&gt;all&lt;/em&gt; of them below the root. The linter whose own authors bypass pathlib for traversal nudges everyone else toward it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hot Take&lt;/strong&gt;&lt;br&gt;
Documenting a footgun is not fixing a footgun. The 3.13 docs note and the 2026 glob-module note upgraded a decade-old surprise from "undocumented behaviour" to "intended behaviour" without adding a single way to opt out. That's the cheapest possible resolution each time, and it's now load-bearing: any future fix would be a documented-behaviour break.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Now the honest question, because you should ask it: did suppression actually &lt;em&gt;cause&lt;/em&gt; our production incident? I can't prove it — and that is precisely the indictment. If an error fired during those listings, &lt;code&gt;rglob&lt;/code&gt; ate it. If the kernel simply returned a stale view, &lt;code&gt;rglob&lt;/code&gt; passed it along. The return value is identical either way: &lt;code&gt;[]&lt;/code&gt;. The API doesn't just fail — it destroys the evidence of &lt;em&gt;how&lt;/em&gt; it failed, which means the first question of any incident review ("what actually happened?") is unanswerable by design.&lt;/p&gt;

&lt;p&gt;What I can offer is the one measurement we did get. While reproducing locally, &lt;code&gt;rglob&lt;/code&gt; would come back empty while &lt;code&gt;os.scandir&lt;/code&gt; — the lower-level directory-scanning call that pathlib itself is built on — returned the full set of files when called at essentially the same instant. Two readers of the same kernel state disagreeing is what turns pathlib from bystander into suspect. Though even here, honesty: the two calls are microseconds apart, and we could never fully separate "pathlib dropped entries" from "the view changed between the calls."&lt;/p&gt;

&lt;p&gt;Switching to &lt;code&gt;os.scandir&lt;/code&gt; closes the silence class entirely — errors propagate, so at minimum you learn &lt;em&gt;which&lt;/em&gt; failure you have. But it can't conjure files the operating system didn't report. That's the next layer down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer Two — The Filesystem Never Promised You a Fresh Listing
&lt;/h2&gt;

&lt;p&gt;Here's the part I would have called a kernel bug, before I read the spec. Whenever any program lists a folder — &lt;code&gt;ls&lt;/code&gt;, Finder, Python's &lt;code&gt;os.listdir&lt;/code&gt;, pathlib's walk — it ultimately goes through one low-level operation: &lt;code&gt;readdir&lt;/code&gt;, the operating system's "tell me what's in this directory" call. POSIX, the standard that defines how that call must behave on Unix-like systems, says something remarkable in the &lt;a href="https://pubs.opengroup.org/onlinepubs/009695399/functions/readdir_r.html" rel="noopener noreferrer"&gt;readdir specification&lt;/a&gt;: "If a file is removed from or added to the directory after the most recent call to opendir() or rewinddir(), whether a subsequent call to readdir() returns an entry for that file is unspecified."&lt;/p&gt;

&lt;p&gt;Unspecified. A listing that races concurrent writes is allowed to miss files, &lt;em&gt;by design&lt;/em&gt;, on every POSIX system. But read the clause carefully, because it's easy to apply too broadly — I did at first: it covers files added &lt;em&gt;while the directory is being read&lt;/em&gt;. Our production pipeline was, as far as we could reconstruct, sequential — write, close, &lt;em&gt;then&lt;/em&gt; list. POSIX has no carve-out for that. A filesystem that returns a stale view after a completed write-and-close isn't exercising the spec's freedom; something else is going on.&lt;/p&gt;

&lt;p&gt;So here is the honest statement about production, on Linux: &lt;strong&gt;we never definitively identified the mechanism.&lt;/strong&gt; The candidates: the container filesystem (production runs in containers, and overlay filesystems have a documented history of readdir caching quirks); writes that weren't as complete as the code believed; or an error that fired and was eaten — and Layer One explains why that last one can never be ruled out. The listing was wrong, the API was silent about why, and the crime scene was already clean by the time anyone looked.&lt;/p&gt;

&lt;p&gt;The local repro on my Mac is where things got measurably stranger — and that's an APFS question. First, the mental model that makes any of this thinkable: creating a file is really &lt;em&gt;two&lt;/em&gt; writes — the file's bytes, and the folder's record that the name exists — and nothing guarantees they become visible at the same instant. APFS is the copy-on-write filesystem on every modern Mac, and the honest answer to what it does to directory listings under pressure is that nobody outside Apple knows.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  In plain English — a new file is two writes, not one
  &lt;p&gt;Creating a file changes two separate things: the file's contents (the bytes you wrote) and the folder's own records saying "a file with this name now exists here" — the &lt;em&gt;metadata&lt;/em&gt;. They are not committed at the same instant. APFS batches its metadata updates for speed using a copy-on-write structure: it builds a new version of the folder's records off to the side and atomically swaps a pointer when it commits. Until that swap happens, both of these can be true at once: the file's bytes are fully written and you can &lt;code&gt;open()&lt;/code&gt; it by name, &lt;em&gt;and&lt;/em&gt; a directory listing still reads the old records and doesn't show it. "The file exists" and "the folder doesn't list it" are not contradictions — they're two different questions asked of two different data structures.&lt;/p&gt;



&lt;p&gt;&lt;/p&gt;

&lt;p&gt;I want to be careful here, because this is the least-documented layer of the three. Apple's &lt;a href="https://developer.apple.com/support/downloads/Apple-File-System-Reference.pdf" rel="noopener noreferrer"&gt;APFS Reference&lt;/a&gt; describes the copy-on-write B-tree design across 181 pages and does not mention &lt;code&gt;readdir&lt;/code&gt; once. There is no published contract for when a freshly created file becomes visible to directory enumeration. What I saw while reproducing locally — files that &lt;code&gt;stat&lt;/code&gt; (the "does this exact path exist?" check) and &lt;code&gt;open&lt;/code&gt; could see by name while a concurrent recursive listing came back without them, under burst writes on an Apple Silicon laptop — is consistent with metadata commits lagging data writes, but I can't point you at an Apple document that says so, because there is no Apple document that says anything about it at all. The silence is the finding.&lt;/p&gt;

&lt;p&gt;What &lt;em&gt;is&lt;/em&gt; on the record is enough to stop giving APFS the benefit of the doubt — with precision about what it shows. In 2020, Giovanni Pizzi of the AiiDA project filed &lt;a href="https://bugs.python.org/issue41291" rel="noopener noreferrer"&gt;bpo-41291&lt;/a&gt;: a &lt;em&gt;delete-versus-open&lt;/em&gt; race, where a file being replaced while another process opens it yields empty reads and &lt;code&gt;st_ino == 0&lt;/code&gt; (an inode number of zero — the file's on-disk identity, reading back as "nothing"). That is &lt;strong&gt;not a listing bug&lt;/strong&gt;, and I'm not claiming it as one — but it is the closest thing on the public record to an Apple-acknowledged APFS race of any kind. CPython's macOS maintainer Ronald Oussoren reproduced it in pure C and bisected it by filesystem: fires on APFS, not on HFS+. His conclusion: "This appears to be a bug in the APFS filesystem implementation." Reported to Apple as FB8009608. No public resolution.&lt;/p&gt;

&lt;p&gt;Two years earlier, Gregory Szorc had &lt;a href="https://gregoryszorc.com/blog/2018/10/29/global-kernel-locks-in-apfs/" rel="noopener noreferrer"&gt;profiled Firefox builds on macOS&lt;/a&gt; and found &lt;code&gt;readdir&lt;/code&gt; on APFS serializing on a global kernel lock — the machine spending ~75% of its CPU time in the kernel just listing directories. A performance finding, not a correctness one — but the same neglected code path, and the same ending: a radar filed, a partial improvement shipped without comment, no acknowledgement.&lt;/p&gt;

&lt;p&gt;One precision note, because it matters for anyone debugging their own version of this: several distinct mechanisms produce the "listing lies" symptom, and they are not the same bug. A writer in another process racing your listing is POSIX-unspecified territory. Files changing while you're mid-walk is a TOCTOU problem — time-of-check to time-of-use, meaning the world changed between when you looked and when you acted. &lt;code&gt;bpo-41291&lt;/code&gt; is an apparent APFS defect. iCloud and Dropbox "dataless" files break globbing in their own special way. And a file you wrote but never flushed is a &lt;em&gt;you&lt;/em&gt; problem. The fix depends on which one you have — and this is where I have to practice what I'm preaching about evidence: production, on Linux, remains formally unsolved; the mechanism candidates above are indistinguishable after the fact. The local Mac repro showed listing inconsistency we could measure but never fully attribute. When your listing API is silent, every one of these mechanisms produces the identical observable: &lt;code&gt;[]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the instinctive fix is: force the pending metadata down before listing — sync the directory. Hold that instinct, because it's about to get complicated twice over: the syncing tool doesn't mean what its name says on a Mac, and it was never a listing-freshness tool &lt;em&gt;anywhere&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer Three — fsync Is a Polite Request on macOS
&lt;/h2&gt;

&lt;p&gt;On Linux, &lt;code&gt;fsync(fd)&lt;/code&gt; — the &lt;code&gt;fd&lt;/code&gt; is a &lt;em&gt;file descriptor&lt;/em&gt;, the numeric handle the OS gives you for anything you've opened, folders included — means: this data is on durable storage before the call returns. On macOS, &lt;code&gt;fsync(fd)&lt;/code&gt; means: this data has been handed to the drive, which may keep it in a volatile cache and write it whenever it feels like it. Apple's own &lt;a href="https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man2/fsync.2.html" rel="noopener noreferrer"&gt;fsync man page&lt;/a&gt; has said so for over twenty years, and it's blunt about the consequences: "This is not a theoretical edge case."&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  In plain English — the three stops between your program and the disk
  &lt;p&gt;When you write a file, the bytes make three stops: your operating system's memory (fast, lost if the OS crashes), the drive's own internal cache (fast, lost if power cuts out), and finally the durable flash storage (survives anything short of hardware failure). &lt;code&gt;fsync&lt;/code&gt; is the call that's supposed to mean "push my data all the way down, then tell me." On Linux it pushes through all three stops. On macOS it stops at the drive's cache — so data that &lt;code&gt;fsync&lt;/code&gt; just claimed was safe can still be eaten by a power cut. The macOS call that pushes all the way is &lt;code&gt;fcntl(fd, F_FULLFSYNC)&lt;/code&gt;, and it's dramatically slower — which is exactly why it isn't the default, and exactly why almost nothing uses it.&lt;/p&gt;



&lt;p&gt;&lt;/p&gt;

&lt;p&gt;The picture is worth having in your head — three stops, and the two systems disagree about which stop "safe" means:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNpVkMFKAzEQhl9lmJNCi6i3RXpavVRQ3JMYD2ky7QaSTZgkLaX03R2ztmBu-ef_viE5oYmWsMMd6zTC64eaQM77l8LPWBkSR5mEpw3frQ7sCt3cKvyG5XIFa-kMJSa4b9O3AQKFyMd28zEXiBNIaljnUaDZvG5sf2EfWtuy2xMYbUb6R6d4IAZTyxXvG_5ywR9nvLLeeIKtl00tyZX3osx_BvFlUeACA3HQzsqDTwrLSIEUdqBwolpYe4VnKdVkdaFn60pk7LbaZ1qgriUOx8lcg7nVO_37QdgVrnT-AXo3bow" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNpVkMFKAzEQhl9lmJNCi6i3RXpavVRQ3JMYD2ky7QaSTZgkLaX03R2ztmBu-ef_viE5oYmWsMMd6zTC64eaQM77l8LPWBkSR5mEpw3frQ7sCt3cKvyG5XIFa-kMJSa4b9O3AQKFyMd28zEXiBNIaljnUaDZvG5sf2EfWtuy2xMYbUb6R6d4IAZTyxXvG_5ywR9nvLLeeIKtl00tyZX3osx_BvFlUeACA3HQzsqDTwrLSIEUdqBwolpYe4VnKdVkdaFn60pk7LbaZ1qgriUOx8lcg7nVO_37QdgVrnT-AXo3bow" alt="Architecture diagram" width="881" height="118"&gt;&lt;/a&gt;&lt;br&gt;
&lt;code&gt;fsync&lt;/code&gt; on Linux pushes your data to stop 3. &lt;code&gt;fsync&lt;/code&gt; on macOS stops at 2 and reports success anyway. The real flush is a separate call, &lt;code&gt;fcntl(fd, F_FULLFSYNC)&lt;/code&gt;, which pushes to stop 3 and has existed since Mac OS X 10.3 in 2003. The canonical explanation came from Apple filesystem engineer Dominic Giampaolo on the &lt;a href="https://postgrespro.com/list/thread-id/1658794" rel="noopener noreferrer"&gt;PostgreSQL mailing list in 2005&lt;/a&gt; — he'd verified drive-cache behaviour with a logic analyzer on the ATA cable, and described the full flush as "a heavy handed operation... But in an app like a database, it is essential." The database world quietly listened: SQLite added &lt;code&gt;PRAGMA fullfsync&lt;/code&gt; in 2004, PostgreSQL grew &lt;code&gt;fsync_writethrough&lt;/code&gt;. Everyone who truly needed durability opted in twenty years ago, which is precisely why there was never enough pressure to change the default.&lt;/p&gt;

&lt;p&gt;The rest of us found out in February 2022, when Asahi Linux's Hector Martin measured an M1's SSD doing &lt;a href="https://mjtsai.com/blog/2022/02/17/apple-ssd-benchmarks-and-f_fullsync/" rel="noopener noreferrer"&gt;~46 write operations per second with real flushes versus ~40,000 without&lt;/a&gt; and called it benchmark cheating. Michael Tsai's roundup of the ensuing fight is the fairest record — including the counter-voices: most non-Apple consumer drives simply lie about flushes, and when Russ Bishop power-loss-tested four NVMe SSDs, half of them lost data they had claimed to flush. Alex Miller's &lt;a href="https://transactional.blog/blog/2022-darwins-deceptive-durability" rel="noopener noreferrer"&gt;"Darwin's Deceptive Durability"&lt;/a&gt; distilled it for database people into three deadpan headings: fsync does not fsync, O_DSYNC does not O_DSYNC, SQLite is not durable.&lt;/p&gt;

&lt;p&gt;This genre has a famous Linux chapter too — fsyncgate, 2018: PostgreSQL's Craig Ringer discovered that when Linux &lt;code&gt;fsync&lt;/code&gt; &lt;em&gt;fails&lt;/em&gt;, the kernel silently discards the data and the next call reports success. His report is dry understatement at its finest — "The write never made it to disk, but we completed the checkpoint, and merrily carried on our way. Whoops, data loss." &lt;a href="https://lwn.net/Articles/752063/" rel="noopener noreferrer"&gt;Jonathan Corbet's LWN writeup&lt;/a&gt; and &lt;a href="https://danluu.com/fsyncgate/" rel="noopener noreferrer"&gt;Dan Luu's verbatim archive&lt;/a&gt; tell it properly, and a later &lt;a href="https://research.cs.wisc.edu/adsl/Publications/cuttlefs-tos21.pdf" rel="noopener noreferrer"&gt;USENIX study&lt;/a&gt; found no major application handles fsync failure correctly even now. The takeaway for this post: durability primitives whose names promise more than they deliver are a genre, not a macOS quirk.&lt;/p&gt;

&lt;p&gt;Now the caveat this section has been building to — and it cuts against our own fix, so it stays in. &lt;code&gt;fsync&lt;/code&gt; is a &lt;em&gt;durability&lt;/em&gt; tool: it controls what survives a power cut. It is not, on any system I can find documentation for, a &lt;em&gt;visibility&lt;/em&gt; tool — nothing in POSIX or Apple's docs says that syncing a directory's file descriptor refreshes what the next &lt;code&gt;readdir&lt;/code&gt; returns on a running machine. On Linux it has no defined effect on our problem at all. On macOS, the theory that &lt;code&gt;F_FULLFSYNC&lt;/code&gt; forces APFS to commit the metadata transaction that feeds enumeration is exactly that — a theory, resting on the same undocumented behaviour as Layer Two. We put the barrier in our fix anyway: it costs 5–20ms on NVMe, and if the theory is right, it tightens the window. But I won't pretend we proved it does anything. Keep that skepticism handy for the next section.&lt;/p&gt;
&lt;h2&gt;
  
  
  How Three Half-Truths Compound
&lt;/h2&gt;

&lt;p&gt;Any one of these alone is trivia. Stacked, they turn the friendliest file API in Python into silent data loss. Read the diagram top to bottom — this is the order it happens in at runtime:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNptkk9PAjEQxb_KpCeIENR42hhOamJINIEDB9fDbDtAQ7ddp1M2G8J3t8uCB7WXNvN-nff656h0MKQKFekrkdf0ZHHLWJce8miQxWrboBdYA0ZYsxXiv-KiFxfEnhzMYGMdxS4K_dNm2ZNLQkMMI966UI0HaD2dzxcFtL3DaAw3oF2I_apKHGVg3oIQhEPemkljmbQE7uBgqQWNHhxuITAQ82PFs_moJr1Db2MNB2RLEaoO3lcXw-VgyDlLbjXKIcnLRVtMs7gsgOpGur7l-Qjozla_s2QOvYHXlzx1vXnmNzmcgdqaaYtuPznHaVB2zlYQc8mFNutWoEz3t3cPkPzehxYrR9dwQwAmSewjfHxeSRfCPoKze4KYtKYY1UTVxDVak9_xWCrZUU2lKqBUnpIwulKdMpQag0LPxuZLU8UGXaSJwiRh1Xn9UxioyzdQhXCi0zfd17gA" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNptkk9PAjEQxb_KpCeIENR42hhOamJINIEDB9fDbDtAQ7ddp1M2G8J3t8uCB7WXNvN-nff656h0MKQKFekrkdf0ZHHLWJce8miQxWrboBdYA0ZYsxXiv-KiFxfEnhzMYGMdxS4K_dNm2ZNLQkMMI966UI0HaD2dzxcFtL3DaAw3oF2I_apKHGVg3oIQhEPemkljmbQE7uBgqQWNHhxuITAQ82PFs_moJr1Db2MNB2RLEaoO3lcXw-VgyDlLbjXKIcnLRVtMs7gsgOpGur7l-Qjozla_s2QOvYHXlzx1vXnmNzmcgdqaaYtuPznHaVB2zlYQc8mFNutWoEz3t3cPkPzehxYrR9dwQwAmSewjfHxeSRfCPoKze4KYtKYY1UTVxDVak9_xWCrZUU2lKqBUnpIwulKdMpQag0LPxuZLU8UGXaSJwiRh1Xn9UxioyzdQhXCi0zfd17gA" alt="Architecture diagram" width="720" height="511"&gt;&lt;/a&gt;&lt;br&gt;
| Layer | What it's supposed to guarantee | What it actually guarantees |&lt;br&gt;
|---|---|---|&lt;br&gt;
| &lt;code&gt;Path.rglob&lt;/code&gt; | Returns every file in the tree | Returns whatever &lt;code&gt;readdir&lt;/code&gt; delivered, silently dropping anything that errored — and never telling you whether anything did |&lt;br&gt;
| &lt;code&gt;readdir&lt;/code&gt; | The current contents of the directory | Under concurrent writes: unspecified by POSIX. After sequential writes: should be current — but APFS's behaviour is undocumented and container filesystems have their own quirks |&lt;br&gt;
| &lt;code&gt;fsync(dir_fd)&lt;/code&gt; | Pending metadata committed to durable storage | Kernel buffers only on macOS (&lt;code&gt;F_FULLFSYNC&lt;/code&gt; for the drive) — and on no system is it a documented listing-freshness barrier |&lt;/p&gt;

&lt;p&gt;In production on Linux we can name the symptom but not the mechanism — the listing was wrong and the API was silent about why. On a Mac dev box, two more undocumented layers join in, which is why the bug was easier to reproduce at my desk than it ever was to catch in the logs. The window is tens of milliseconds. The probability per call is tiny. The probability across a multi-hour pipeline run writing thousands of files is not — and every miss looks exactly like a legitimately empty directory.&lt;/p&gt;

&lt;p&gt;The reframe that finally made the fix obvious: a directory listing is not a query. It's a measurement. It has noise, it has a sampling window, and if you need it to be right, you have to engineer for that the way you would for any other measurement.&lt;/p&gt;
&lt;h2&gt;
  
  
  What We Shipped
&lt;/h2&gt;

&lt;p&gt;First: do you even need this? Only if your code lists a directory &lt;em&gt;in the same breath&lt;/em&gt; as writing into it — write-then-immediately-list, or listing while writers may still be active. A static folder read at startup is fine with plain &lt;code&gt;rglob&lt;/code&gt;. If that's you, close this tab guilt-free.&lt;/p&gt;

&lt;p&gt;For everyone else, four moves, each honest about what it does and doesn't buy:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Replace &lt;code&gt;rglob&lt;/code&gt; with an &lt;code&gt;os.scandir&lt;/code&gt; recursion that raises on error.&lt;/strong&gt; The one &lt;em&gt;provable&lt;/em&gt; fix: silence becomes a loud failure, so you always know which kind of empty you got.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry briefly with backoff.&lt;/strong&gt; The unglamorous move that, in truth, probably does most of the work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nudge the directory metadata before each attempt&lt;/strong&gt; — &lt;code&gt;F_FULLFSYNC&lt;/code&gt; on macOS, &lt;code&gt;os.fsync&lt;/code&gt; elsewhere. This is the theory-not-proof part flagged above: cheap, harmless, unproven.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A tripwire and a loud log.&lt;/strong&gt; Two same-instant top-level listings that disagree mean the directory is visibly in flux; if it never settles, operators hear about it instead of nobody.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The block below is complete — imports, exception, logging — paste it and it runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;fcntl&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;

&lt;span class="n"&gt;logger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getLogger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# In the stdlib on macOS builds of Python; the raw value elsewhere is inert.
&lt;/span&gt;&lt;span class="n"&gt;F_FULLFSYNC&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fcntl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;F_FULLFSYNC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;51&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StorageListingError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;A directory listing failed — surfaced instead of swallowed.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_flush_directory_metadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Nudge the kernel to commit pending directory metadata.

    fsync is a durability primitive; treating it as a listing-freshness
    barrier is a theory, not a documented contract. Kept because it is
    cheap and cannot make things worse.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;fd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;O_DIRECTORY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;darwin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;fcntl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fcntl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;F_FULLFSYNC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;OSError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# e.g. network/virtual filesystems
&lt;/span&gt;        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_scandir_recursive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Like Path.rglob(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;) for files, except errors raise instead of
    silently shrinking the result.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scandir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_dir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;follow_symlinks&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_scandir_recursive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
                &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;follow_symlinks&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;OSError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;StorageListingError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;could not list &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;safe_list_directory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Recursive file listing, hardened for directories written moments ago.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;backoffs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;backoffs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;backoffs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
        &lt;span class="nf"&gt;_flush_directory_metadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;listing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_scandir_recursive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;# Tripwire, not proof: two top-level listings taken back-to-back
&lt;/span&gt;        &lt;span class="c1"&gt;# that DISAGREE mean the directory is visibly in flux. Agreement
&lt;/span&gt;        &lt;span class="c1"&gt;# does not prove the recursive listing is complete.
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;iterdir&lt;/span&gt;&lt;span class="p"&gt;()}:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;listing&lt;/span&gt;
        &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;listing&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;directory listing never converged: path=%s attempts=%d&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It drops in at every &lt;code&gt;rglob&lt;/code&gt; call site with one deliberate behavioural change: where &lt;code&gt;rglob&lt;/code&gt; silently returned partial results, this &lt;em&gt;raises&lt;/em&gt;. That is the point — if your caller can't handle a &lt;code&gt;StorageListingError&lt;/code&gt;, it was already mishandling the invisible version of the same event.&lt;/p&gt;

&lt;p&gt;Which parts can I defend, and how hard? The scandir-and-raise part: fully — it provably closes the silence class. The retry: empirically — it's the standard answer to transient inconsistency, whatever the mechanism. The flush: it's the belt-and-braces theory flagged in Layer Three; we never ran the ablation that would separate its contribution from the retry's, and I'd genuinely love to see someone do it. The tripwire: it detects churn between two instants, not stable staleness — a steadily wrong view sails through it, which is why the warning log exists. The whole thing is probabilistic, not deterministic. We tightened the window; we did not close it. The Darwin branch exists because dev machines live on Macs even though production is Linux; the cost on a Mac is 5–20ms of &lt;code&gt;F_FULLFSYNC&lt;/code&gt; per attempt, far cheaper on Linux, and negligible against upload latencies either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fixes That Don't Trust Listings
&lt;/h2&gt;

&lt;p&gt;Closing the window for real means restructuring so the race can't exist, and every mature system that has faced this converged on one of three shapes — none of which trust a directory listing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sentinel markers.&lt;/strong&gt; The writer touches a marker file last, after everything is flushed; readers refuse to list until the marker exists. Hadoop's &lt;code&gt;_SUCCESS&lt;/code&gt; file, carried forward by Spark, Hive, and Iceberg.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Atomic rename.&lt;/strong&gt; Write into a staging directory, flush, then &lt;code&gt;rename&lt;/code&gt; it into place — POSIX guarantees observers see the old state or the new state, never a partial one. Maildir has done this since 1995; Git's &lt;code&gt;index.lock&lt;/code&gt; is the same idea.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kernel events instead of listings.&lt;/strong&gt; Subscribe to FSEvents/inotify before writing and build the file set from events. Watchman, Buck2, and Mercurial's fsmonitor live here.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's an optimistic precedent for the "or the platform just fixes it" ending, too. S3's LIST calls were eventually consistent for fourteen years, and it bit production hard enough that Netflix built &lt;a href="https://netflixtechblog.com/s3mper-consistency-in-the-cloud-b6a1076aa4f8" rel="noopener noreferrer"&gt;s3mper&lt;/a&gt; — Daniel Weeks's 2014 writeup could be this post's abstract: "there is no indication that a problem occurred... if only a small portion of input is missing the results will appear convincing." Hadoop built S3Guard for the same reason. Then in December 2020, &lt;a href="https://aws.amazon.com/blogs/aws/amazon-s3-update-strong-read-after-write-consistency/" rel="noopener noreferrer"&gt;AWS made S3 strongly consistent&lt;/a&gt; for everyone, retroactively, for free — Werner Vogels's &lt;a href="https://www.allthingsdistributed.com/2021/04/s3-strong-consistency.html" rel="noopener noreferrer"&gt;engineering post-mortem&lt;/a&gt; notes the formal verification took more work than the implementation. The S3Guard docs now just say: delete the DynamoDB table.&lt;/p&gt;

&lt;p&gt;Sit with the irony for a second: an object store on the other side of the planet now gives you stronger listing guarantees than the filesystem on the laptop you're reading this on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The People Who Dug First
&lt;/h2&gt;

&lt;p&gt;Almost nothing in this post is my discovery. The record was public the whole time — it just took a production incident to make me read it. Credit where it belongs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The pathlib thread&lt;/strong&gt;: Gregorio (the 2015 report), Serhiy Storchaka (the road not taken), Ulrich Petri (the patch), Guido van Rossum (the warning that came true), Thierry Parmentelat and Matt Wozniski (the strace proof), Pablo Galindo (the regression fix), Barney Gale (3.13's resolution), and Jakub Kuczys (the 2026 issue that finally got it documented).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Darwin durability thread&lt;/strong&gt;: Dominic Giampaolo (the 2005 explanation that still holds), Hector Martin (the numbers), Michael Tsai (the fairest record of the fight), Alex Miller (the essay to read first), and Howard Oakley (the calm explainer).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The fsyncgate thread&lt;/strong&gt;: Craig Ringer (the discovery), Jonathan Corbet (the no-villains account), Dan Luu (the archive, and the whole "files are hard" genre), Thomas Munro and Andres Freund (the fix), and Ted Ts'o (the kernel's side of the story, which deserves to be heard).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The APFS thread&lt;/strong&gt;: Giovanni Pizzi and Ronald Oussoren (bpo-41291 — the one Apple-acknowledged APFS race, a delete-vs-open one rather than a listing one), and Gregory Szorc (the readdir profiling). This thread is the shortest because almost nobody has dug here. That should probably change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you take one action from this post, make it structural: treat directory listings as measurements, surface your errors, and when correctness matters, publish atomically or use a marker. The standard library teaches you to trust the listing. Production says don't.&lt;/p&gt;




&lt;p&gt;The scholars of Ohara, with the Buster Call bearing down on them, threw their books into the lake so the knowledge would outlive the island. Twenty years later it was all still there, waiting for someone willing to dive.&lt;/p&gt;

&lt;p&gt;The knowledge about this bug was never hidden either. It's been sitting in open issue trackers, mailing list archives, and man pages since before some of the people hitting it learned Python — eleven years of it in one CPython thread alone. Nobody burned the library. We just stopped reading the poneglyphs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxhckylq4lrzwztiin3kf.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxhckylq4lrzwztiin3kf.jpg" alt="The lake at Ohara filled with the books the scholars saved from the burning Tree of Knowledge" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;Intro and closing images are from the One Piece anime via the &lt;a href="https://onepiece.fandom.com/" rel="noopener noreferrer"&gt;One Piece Wiki&lt;/a&gt;. Attribution and credits — please &lt;a href="mailto:prateek11rai@protonmail.com"&gt;contact&lt;/a&gt; if anything needs updating.&lt;/p&gt;

</description>
      <category>python</category>
      <category>macos</category>
      <category>debugging</category>
      <category>filesystem</category>
    </item>
    <item>
      <title>Your Blog Deserves a Second Life: Syndicating to dev.to with Canonical URLs</title>
      <dc:creator>Prateek Rai</dc:creator>
      <pubDate>Sun, 28 Jun 2026 10:54:50 +0000</pubDate>
      <link>https://dev.to/prateek11rai/your-blog-deserves-a-second-life-syndicating-to-devto-with-canonical-urls-2ped</link>
      <guid>https://dev.to/prateek11rai/your-blog-deserves-a-second-life-syndicating-to-devto-with-canonical-urls-2ped</guid>
      <description>&lt;p&gt;I spent months writing posts for this blog. Each one went live on the site, got maybe three readers (one of them me checking the RSS feed), and sat there in peace.&lt;/p&gt;

&lt;p&gt;Then I started copy-pasting the same content to dev.to manually — fixing broken image paths, stripping out MkDocs-specific markdown, re-pasting. Every. Single. Post.&lt;/p&gt;

&lt;p&gt;The third time I did it, I wrote a Python script instead. That script became a CI pipeline. That pipeline became the subject of this post.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4soy3yyr7qgn8l94pnwg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4soy3yyr7qgn8l94pnwg.png" alt="The Going Merry — the sending-ship that carried the Straw Hats from their private dock to the public ports of the world, over and over again" width="800" height="723"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Canonical URL Problem
&lt;/h2&gt;

&lt;p&gt;Here is the thing that kept me up at night: dev.to has a domain authority of 89. Your personal blog has a domain authority of about 4 (if you are lucky). If you post the same content on both without telling Google which one is original, guess which one ranks first?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hint: not yours.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is where &lt;code&gt;canonical_url&lt;/code&gt; comes in. It is an HTML &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; tag that tells search engines: "This article originally lives at this URL. Index that one, not me." dev.to supports it natively — if you include &lt;code&gt;canonical_url&lt;/code&gt; in your API payload, every dev.to article page renders a &lt;code&gt;&amp;lt;link rel="canonical" href="..."&amp;gt;&lt;/code&gt; in the head. Google sees it and credits the original source.&lt;/p&gt;

&lt;p&gt;Without canonical URLs, dev.to syndication actively harms your SEO. With them, it becomes a pure distribution channel — your site keeps the ranking, dev.to gets the traffic.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Hot Take&lt;/strong&gt;&lt;br&gt;
If you syndicate to dev.to without setting a canonical URL, you are actively cannibalizing your own SEO. dev.to's domain authority means Google will rank their copy above yours every time. Canonical URLs are not a nice-to-have — they are the difference between syndication helping you and hurting you.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The canonical URL for this very post? &lt;code&gt;https://prateek11rai.github.io/sanji/blog/2026/06/28/devto-syndication/&lt;/code&gt;. The script builds it from the front matter date and the slug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dev.to API
&lt;/h2&gt;

&lt;p&gt;The dev.to API is refreshingly simple. No OAuth flow, no Bearer tokens, no scopes to negotiate. You generate an API key from &lt;code&gt;dev.to → Settings → Account → API Keys&lt;/code&gt; and send it as a header.&lt;/p&gt;

&lt;h3&gt;
  
  
  Authentication
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;api-key: YOUR_DEVTO_API_KEY
Content-Type: application/json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is it. One header. No &lt;code&gt;Authorization: Bearer&lt;/code&gt;, no refresh tokens.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Payload
&lt;/h3&gt;

&lt;p&gt;The entire payload nests under a single &lt;code&gt;article&lt;/code&gt; key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"article"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Your Blog Deserves a Second Life"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"body_markdown"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"# The full markdown content..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"published"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"canonical_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://prateek11rai.github.io/sanji/blog/2026/06/28/devto-syndication/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Short excerpt shown in previews"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"tags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"tooling"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"python"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"meta"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"main_image"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://raw.githubusercontent.com/prateek11rai/sanji/main/docs/assets/images/blog/devto-syndication/devto-intro.png"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Endpoints
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/articles&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;POST&lt;/td&gt;
&lt;td&gt;Create new article&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/articles/{id}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PUT&lt;/td&gt;
&lt;td&gt;Update existing article&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/articles/me/all?per_page=100&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;GET&lt;/td&gt;
&lt;td&gt;List all articles (including drafts)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/articles/me/published?per_page=100&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;GET&lt;/td&gt;
&lt;td&gt;List published articles only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Rate Limits
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;10 POST&lt;/strong&gt; requests per 30 seconds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;30 PUT&lt;/strong&gt; requests per 30 seconds&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We hit the POST limit during the first bulk syndication. The API does not return rate-limit headers — you only learn you hit the wall when a &lt;code&gt;429&lt;/code&gt; comes back. The fix is a retry with backoff, which I added after the first batch failed halfway through.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Trivia&lt;/strong&gt;&lt;br&gt;
The dev.to API has no &lt;code&gt;DELETE /api/articles/{id}&lt;/code&gt; endpoint. If you need to remove a syndicated article, you must delete it manually through the web dashboard. This is surprisingly common among community-focused platforms — they want human oversight on deletions.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Tag Restrictions
&lt;/h3&gt;

&lt;p&gt;dev.to tags must be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Purely alphanumeric (no hyphens, no spaces, no special characters)&lt;/li&gt;
&lt;li&gt;Max 4 tags per article&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This means &lt;code&gt;self-hosting&lt;/code&gt; (MkDocs category) becomes &lt;code&gt;selfhosting&lt;/code&gt; (dev.to tag). Our script lowercases categories and strips hyphens automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Draft Quirk
&lt;/h3&gt;

&lt;p&gt;Setting &lt;code&gt;published: false&lt;/code&gt; creates an unpublished article — but &lt;code&gt;GET /api/articles/{id}&lt;/code&gt; returns a 404 for drafts. You must use &lt;code&gt;/me/all&lt;/code&gt; to find them. This caught us during development when we tried to verify a draft by fetching it back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Transformation Pipeline
&lt;/h2&gt;

&lt;p&gt;MkDocs Material produces beautiful markdown — for MkDocs Material. dev.to uses its own renderer (based on Redcarpet/github-linguist), and the two speak completely different dialects.&lt;/p&gt;

&lt;p&gt;Here is every transform our &lt;code&gt;syndicate.py&lt;/code&gt; script applies, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Strip ``&lt;/strong&gt; — MkDocs excerpt marker, irrelevant on dev.to&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strip &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; blocks&lt;/strong&gt; — Material inline styles that break dev.to rendering&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strip `` grid cards&lt;/strong&gt; — Material's HTML grid markup is stripped by dev.to anyway&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render Mermaid diagrams as images&lt;/strong&gt; — &lt;code&gt;&lt;/code&gt;`&lt;code&gt;mermaid&lt;/code&gt; blocks become &lt;code&gt;![Architecture diagram](https://mermaid.ink/img/pako:{compressed})&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strip ``&lt;/strong&gt; — MkDocs image attribute, dev.to does not support it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strip attr_list attributes&lt;/strong&gt; — `` annotations from the attr_list plugin&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replace Material icons&lt;/strong&gt; — &lt;code&gt;✅&lt;/code&gt; becomes &lt;code&gt;✅&lt;/code&gt;, unknown icons are removed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Convert admonitions to blockquotes&lt;/strong&gt; — &lt;code&gt;!!! note "Title"\n    content&lt;/code&gt; becomes &lt;code&gt;&amp;gt; **Title**\n&amp;gt; content&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rewrite image URLs&lt;/strong&gt; — &lt;code&gt;../../assets/images/foo.jpg&lt;/code&gt; → &lt;code&gt;https://raw.githubusercontent.com/...&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unwrap footnote definitions&lt;/strong&gt; — &lt;code&gt;: text&lt;/code&gt; moved to bottom under a &lt;code&gt;---&lt;/code&gt; separator&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strip footnote references&lt;/strong&gt; — `` inline refs removed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strip H1 title&lt;/strong&gt; — The &lt;code&gt;# Title&lt;/code&gt; is removed (dev.to uses its own title field from the payload)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The most complex transform is the Mermaid rendering, which deserves its own section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mermaid Rendering
&lt;/h2&gt;

&lt;p&gt;MkDocs Material renders Mermaid diagrams natively in the browser using JavaScript. dev.to does not. The solution: pre-render every Mermaid block as an image using the free &lt;a href="https://mermaid.ink" rel="noopener noreferrer"&gt;mermaid.ink&lt;/a&gt; service.&lt;/p&gt;

&lt;p&gt;The trick is in how mermaid.ink accepts diagrams. It expects a URL-safe, zlib-compressed, base64-encoded JSON payload in the path. The payload structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"graph TB&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;    A[Post] --&amp;gt; B(syndicate.py)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mermaid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;theme&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;neutral&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"updateEditor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"autoSync"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"updateDiagram"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Our script compresses this, encodes it, and produces a URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_render_mermaid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_repl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Match&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mermaid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;theme&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;neutral&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;updateEditor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;autoSync&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;updateDiagram&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;separators&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;compressed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;zlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;encoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlsafe_b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;compressed&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://mermaid.ink/img/pako:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;encoded&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;![Architecture diagram](&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;```

mermaid\n(.*?)

```\s*\n*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_repl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DOTALL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result is an inline image that renders the diagram exactly as it would on the site. The &lt;code&gt;level=9&lt;/code&gt; compression is crucial — Mermaid payloads can be large, and URLs have length limits.&lt;/p&gt;

&lt;p&gt;Here is the full flow visualized:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNqFUk1LxDAQ_SshRz-2F_GwiLDb7sLCBkPbPaUe0mbcjaZJSVJBxf9u2lStKDiHdt6bN8nLMG-4MQLwEh8t705on1cahXB9HYkKF1w_SrRW5ljhWByCZIw8ZaZxiBrnb2qb3IqAkjroki5QLjlbtOL-u2NL2NYa7RHh3oONLdwDOkdN-B2NleAmPWgRk99uXrSQgx4VjZWdn3sqKHOf5UX3Mru7pKy0XLsHY1tEZQdKapjV04ylXBsdWhU65Hu07qUSYGcSkjMCtuVSoDzYAzs94VXJOjyh5g6ur_61L-B54c3c9IruWGSHdDwy4Z1MuPWyUV8TGYLeFSUbPihB9FDOKqu83KX7DaN9raQ7gUCr2P63IZKhy8vbMK8It2SEaRZhQUdYTtUyQjJtRhp7g9fprPwnDslIDD4jMzoeNdElvsBtnGRYu7cK-xO0UOFlGI-G3luuKvweRH03rMdGSG8sXj5w5eAC896bsAPNFxFVmeRhxC1eetvD-wdR7dfg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNqFUk1LxDAQ_SshRz-2F_GwiLDb7sLCBkPbPaUe0mbcjaZJSVJBxf9u2lStKDiHdt6bN8nLMG-4MQLwEh8t705on1cahXB9HYkKF1w_SrRW5ljhWByCZIw8ZaZxiBrnb2qb3IqAkjroki5QLjlbtOL-u2NL2NYa7RHh3oONLdwDOkdN-B2NleAmPWgRk99uXrSQgx4VjZWdn3sqKHOf5UX3Mru7pKy0XLsHY1tEZQdKapjV04ylXBsdWhU65Hu07qUSYGcSkjMCtuVSoDzYAzs94VXJOjyh5g6ur_61L-B54c3c9IruWGSHdDwy4Z1MuPWyUV8TGYLeFSUbPihB9FDOKqu83KX7DaN9raQ7gUCr2P63IZKhy8vbMK8It2SEaRZhQUdYTtUyQjJtRhp7g9fprPwnDslIDD4jMzoeNdElvsBtnGRYu7cK-xO0UOFlGI-G3luuKvweRH03rMdGSG8sXj5w5eAC896bsAPNFxFVmeRhxC1eetvD-wdR7dfg" alt="Architecture diagram" width="1751" height="292"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The CI Workflow
&lt;/h2&gt;

&lt;p&gt;The workflow lives in &lt;code&gt;.github/workflows/syndicate.yml&lt;/code&gt; and runs on every push to &lt;code&gt;main&lt;/code&gt;. It detects which blog posts changed, runs the syndication script on each one, and publishes (or updates) them on dev.to.&lt;/p&gt;

&lt;p&gt;Key excerpt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Syndicate blog to dev.to&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;syndicate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Detect changed blog posts&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;changed&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;BEFORE="${{ github.event.before }}"&lt;/span&gt;
          &lt;span class="s"&gt;if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then&lt;/span&gt;
            &lt;span class="s"&gt;FILES=$(git diff-tree --no-commit-id -r "${{ github.sha }}" \&lt;/span&gt;
              &lt;span class="s"&gt;--name-only -- 'docs/blog/posts/*.md')&lt;/span&gt;
          &lt;span class="s"&gt;else&lt;/span&gt;
            &lt;span class="s"&gt;FILES=$(git diff --name-only "$BEFORE" "${{ github.sha }}" \&lt;/span&gt;
              &lt;span class="s"&gt;-- 'docs/blog/posts/*.md')&lt;/span&gt;
          &lt;span class="s"&gt;fi&lt;/span&gt;
          &lt;span class="s"&gt;FILES=$(echo "$FILES" | grep -v 'sanji\.md$' || true)&lt;/span&gt;
          &lt;span class="s"&gt;echo "changed&amp;lt;&amp;lt;DELIM" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;
          &lt;span class="s"&gt;echo "$FILES" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;
          &lt;span class="s"&gt;echo "DELIM" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Syndicate to dev.to&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;steps.changed.outputs.changed != ''&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;echo "$CHANGED_FILES" | while IFS= read -r file; do&lt;/span&gt;
            &lt;span class="s"&gt;[ -z "$file" ] &amp;amp;&amp;amp; continue&lt;/span&gt;
            &lt;span class="s"&gt;uv run python scripts/syndicate.py "$file" --publish&lt;/span&gt;
          &lt;span class="s"&gt;done&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;CHANGED_FILES&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.changed.outputs.changed }}&lt;/span&gt;
          &lt;span class="na"&gt;DEVTO_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DEVTO_API_KEY }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few design decisions worth noting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fetch-depth: 0&lt;/code&gt;&lt;/strong&gt; is required because the workflow needs the full git history for &lt;code&gt;git diff&lt;/code&gt; to compare against the previous commit. A shallow clone would fail here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The zero-SHA check&lt;/strong&gt; handles the first push to a branch, where &lt;code&gt;github.event.before&lt;/code&gt; is all zeros. In that case, &lt;code&gt;git diff-tree&lt;/code&gt; lists every file in the initial commit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sanji.md&lt;/code&gt; is filtered out&lt;/strong&gt; — that is the about page, not a blog post.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;uv run&lt;/code&gt;&lt;/strong&gt; is used instead of raw &lt;code&gt;python&lt;/code&gt; because the project uses uv for Python package management. Much faster than pip, lockfile for reproducibility.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Canonical URL Builder
&lt;/h2&gt;

&lt;p&gt;The most important function in the script. Every post gets a deterministic URL based on its front matter date and title:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_payload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post_file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;post_file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;fm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_front_matter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;post_file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;make_slug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;tags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;categories&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])][:&lt;/span&gt;&lt;span class="n"&gt;MAX_TAGS&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;tags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;meta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;date_str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;date_parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;date_str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;canonical_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;SITE_BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/blog/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;date_parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;date_parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;date_parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;canonical_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;SITE_BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/blog/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="c1"&gt;# ... payload assembly continues ...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The slug is generated using &lt;code&gt;pymdownx.slugs.uslugify(title, "-")&lt;/code&gt; — the same function MkDocs Material uses internally. This ensures the dev.to canonical URL matches the exact URL the blog generates at build time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency &amp;amp; Dedup
&lt;/h2&gt;

&lt;p&gt;The worst thing a syndication script can do is create duplicates. Our script prevents this by checking the existing articles before creating:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;find_existing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;canonical_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api-key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;DEVTO_API&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/me/all?per_page=100&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;DEVTO_API&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/me/published?per_page=100&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;article&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;canonical_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;canonical_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If an article with matching &lt;code&gt;canonical_url&lt;/code&gt; exists, it calls PUT instead of POST. The same post can be re-syndicated a hundred times — it creates one article, updated in place.&lt;/p&gt;

&lt;p&gt;The script also preserves the existing &lt;code&gt;published&lt;/code&gt; state on updates unless &lt;code&gt;--publish&lt;/code&gt; is explicitly passed. This means a draft post that gets edited stays a draft on dev.to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Matters
&lt;/h2&gt;

&lt;p&gt;Syndication infrastructure is invisible when it works and very visible when it does not. The script runs on every push, transforms every block, and posts to dev.to without anyone thinking about it. That is the point.&lt;/p&gt;




&lt;p&gt;The Going Merry was not the biggest or strongest ship. It was a caravel — an obsolete design by Grand Line standards. But it carried the Straw Hats through East Blue, across Reverse Mountain, into the sky, and all the way to Enies Lobby. It was the sending-ship that connected their private world to the public one.&lt;/p&gt;

&lt;p&gt;This syndication pipeline is the same thing. A small, purpose-built tool that carries each post from my personal dock to the public port. The canonical URL is the flag in the mast — everyone knows where it came from.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ficnx1qf0c0bh0zh9jww9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ficnx1qf0c0bh0zh9jww9.png" alt="Transponder Snail — the Den Den Mushi that broadcasts signals across islands, always keeping the original message intact" width="799" height="451"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;Image credit and attribution — One Piece artwork is the property of Eiichiro Oda and Shueisha. Please &lt;a href="mailto:prateek11rai@protonmail.com"&gt;contact&lt;/a&gt; if anything needs updating.&lt;/p&gt;

</description>
      <category>devto</category>
      <category>ci</category>
      <category>seo</category>
      <category>python</category>
    </item>
    <item>
      <title>Crafting Your Terminal Like a Chef Sharpens His Knives</title>
      <dc:creator>Prateek Rai</dc:creator>
      <pubDate>Sat, 27 Jun 2026 19:27:21 +0000</pubDate>
      <link>https://dev.to/prateek11rai/crafting-your-terminal-like-a-chef-sharpens-his-knives-2d8c</link>
      <guid>https://dev.to/prateek11rai/crafting-your-terminal-like-a-chef-sharpens-his-knives-2d8c</guid>
      <description>&lt;p&gt;I used the default macOS Terminal for years and thought that was just how terminals look.&lt;/p&gt;

&lt;p&gt;Then I saw someone else's — proper font, syntax highlighting, a prompt that actually tells you something — and realized mine looked like a hospital monitor from the 90s.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdwuknkyvx8rws8og7bs4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdwuknkyvx8rws8og7bs4.png" alt="Young Sanji cooking at the Baratie — learning the craft early" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not the Defaults
&lt;/h2&gt;

&lt;p&gt;macOS ships with Terminal.app and bash (well, zsh now, but the point stands). They are designed to be adequate, not good. Here is what I wanted instead:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;Why It Falls Short&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Terminal.app&lt;/td&gt;
&lt;td&gt;No GPU acceleration, no Lua config, no cross-platform&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bash&lt;/td&gt;
&lt;td&gt;No autosuggestions, weaker completion than zsh&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bare prompt&lt;/td&gt;
&lt;td&gt;No git status, no Python version, no command timing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No multiplexer&lt;/td&gt;
&lt;td&gt;Lose your session when the window closes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Default font&lt;/td&gt;
&lt;td&gt;Menlo at 12px — designed for legibility, not for staring at 8 hours a day&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I wanted a terminal that felt like my space — configured exactly how I like it, persistent across sessions, and beautiful enough that I do not mind spending hours in it. The result is a stack of four tools that each do one thing well: &lt;strong&gt;WezTerm&lt;/strong&gt; for rendering, &lt;strong&gt;Tmux&lt;/strong&gt; for session management, &lt;strong&gt;Zsh&lt;/strong&gt; for the shell, and &lt;strong&gt;Starship&lt;/strong&gt; for the prompt. All tied together with a single dotfiles repo and a bootstrap script that sets up a new Mac in one command.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Hot Take&lt;/strong&gt;&lt;br&gt;
iTerm2 is the default recommendation for anyone switching from Terminal.app, and I think it is overrated. It is heavy, its tab management has always felt bolted on, and its Python scripting API is powerful but unnecessary for 95% of users. WezTerm does everything iTerm2 does but with a config file you can version-control and a GPU-accelerated renderer that actually matters on high-DPI displays.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  WezTerm — The Terminal Itself
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://wezfurlong.org/wezterm/" rel="noopener noreferrer"&gt;WezTerm&lt;/a&gt; is a GPU-accelerated terminal emulator configured entirely through Lua. No GUI settings panel, no hidden preferences — just a &lt;code&gt;wezterm.lua&lt;/code&gt; file you check into git.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Config
&lt;/h3&gt;

&lt;p&gt;The main file lives at &lt;code&gt;~/.config/wezterm/wezterm.lua&lt;/code&gt;, split into modules loaded with &lt;code&gt;require()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~/.config/wezterm/
├── wezterm.lua           # Main config
├── startup/
│   └── init.lua          # Auto-attach tmux + maximize on launch
└── keybindings/
    └── init.lua          # Custom keybindings (C-S-f to fit screen)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key settings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;enable_tab_bar = false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Off&lt;/td&gt;
&lt;td&gt;Tmux handles tabs — WezTerm just renders&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;window_decorations = "RESIZE"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Minimal&lt;/td&gt;
&lt;td&gt;No title bar, no close/minimize buttons&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;font = JetBrains Mono Bold&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bold&lt;/td&gt;
&lt;td&gt;Clean, developer-friendly, bold for readability&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;font_size = 15&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;15px&lt;/td&gt;
&lt;td&gt;Comfortable without wasting space&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;color_scheme = "Dracula (Official)"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Dracula&lt;/td&gt;
&lt;td&gt;Consistent theme across everything&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;window_background_opacity = 0.99&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Near-solid&lt;/td&gt;
&lt;td&gt;Slight transparency for a modern feel&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The Startup Module
&lt;/h3&gt;

&lt;p&gt;This is the glue that connects WezTerm to Tmux:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- ~/.config/wezterm/startup/init.lua&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;wezterm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"gui-startup"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;tmux_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/opt/homebrew/bin/tmux"&lt;/span&gt;
        &lt;span class="n"&gt;wezterm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mux&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spawn_window&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;tmux_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"new-session"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"-A"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When WezTerm launches, it runs &lt;code&gt;tmux new-session -A&lt;/code&gt; — attach to the most recent Tmux session if one exists, or create a new one. The full path to &lt;code&gt;tmux&lt;/code&gt; is required because WezTerm's spawn process does not inherit the user shell PATH.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Keybindings Module
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- ~/.config/wezterm/keybindings/init.lua&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"F"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;mods&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"CTRL|SHIFT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;wezterm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="n"&gt;FitToDisplay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One binding: &lt;code&gt;Ctrl+Shift+F&lt;/code&gt; fits the window to the active display. This exists because disconnecting an external monitor leaves the terminal window sized for a larger screen. One keystroke fixes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tmux — The Session Manager
&lt;/h2&gt;

&lt;p&gt;WezTerm renders the pixels. Tmux keeps your work alive. The two together are better than either alone.&lt;/p&gt;

&lt;p&gt;Config at &lt;code&gt;~/.config/tmux/tmux.conf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;set -g prefix C-a                # Changed from C-b — easier reach
set -g mouse on                   # Click panes, scroll, resize with mouse
bind r source-file ~/.config/tmux/tmux.conf  # Quick reload
bind i run ~/.config/tmux/plugins/tpm/bin/install_plugins
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Plugins
&lt;/h3&gt;

&lt;p&gt;Managed by TPM (Tmux Plugin Manager):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plugin&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tmux-plugins/tpm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Plugin manager itself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tmux-plugins/tmux-sensible&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Saner defaults (better escape time, UTF-8, improved pane behavior)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tmux-plugins/tmux-yank&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Copy to system clipboard from inside tmux&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Why Tmux Over WezTerm Tabs
&lt;/h3&gt;

&lt;p&gt;WezTerm can do tabs natively. I chose Tmux because:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Persistence&lt;/strong&gt; — close WezTerm, reopen it, and you are back in the same Tmux session. Everything is where you left it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSH&lt;/strong&gt; — Tmux works over SSH exactly as it works locally. Detach from a remote session, reconnect later from a different machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Muscle memory&lt;/strong&gt; — Tmux has been doing this since 2007. Every terminal multiplexer since then has been catching up.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Zsh — The Shell
&lt;/h2&gt;

&lt;p&gt;The shell config at &lt;code&gt;~/.zshrc&lt;/code&gt; sets up the environment that WezTerm and Tmux run inside:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Prompt&lt;/span&gt;
&lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;starship init zsh&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Python&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PYENV_ROOT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.pyenv"&lt;/span&gt;
&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; pyenv &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;pyenv init -&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Editor&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;EDITOR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"code --wait"&lt;/span&gt;

&lt;span class="c"&gt;# Autosuggestions&lt;/span&gt;
&lt;span class="nb"&gt;source&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;brew &lt;span class="nt"&gt;--prefix&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;/share/zsh-autosuggestions/zsh-autosuggestions.zsh"&lt;/span&gt;

&lt;span class="c"&gt;# Java&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;JAVA_HOME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/Library/Java/JavaVirtualMachines/jdk-22.jdk/Contents/Home"&lt;/span&gt;

&lt;span class="c"&gt;# Corporate SSL (Netskope — only on work machines)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"/Library/Application Support/Netskope/STAgent/stagent.pem"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_CA_BUNDLE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/Library/Application Support/Netskope/STAgent/stagent.pem"&lt;/span&gt;
    &lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CURL_CA_BUNDLE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/Library/Application Support/Netskope/STAgent/stagent.pem"&lt;/span&gt;
    &lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SSL_CERT_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/Library/Application Support/Netskope/STAgent/stagent.pem"&lt;/span&gt;
    &lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;NODE_EXTRA_CA_CERTS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/Library/Application Support/Netskope/STAgent/stagent.pem"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Aliases&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;vc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"vcluster platform connect vcluster"&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;ap&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"argopm install . -n default -f -c ."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Netskope block is only active on corporate machines where the cert file exists. On personal machines, those env vars are never set, and nothing breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Starship — The Prompt
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://starship.rs" rel="noopener noreferrer"&gt;Starship&lt;/a&gt; is a cross-shell prompt written in Rust. It is fast, minimal, and shows contextual information without clutter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# ~/.config/starship.toml&lt;/span&gt;
&lt;span class="c"&gt;# Each module gets its own format string&lt;/span&gt;
&lt;span class="nn"&gt;[git_branch]&lt;/span&gt;
&lt;span class="py"&gt;format&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"on [$symbol$branch]($style) "&lt;/span&gt;

&lt;span class="nn"&gt;[python]&lt;/span&gt;
&lt;span class="py"&gt;format&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"via [${symbol}${version}]($style) "&lt;/span&gt;

&lt;span class="nn"&gt;[cmd_duration]&lt;/span&gt;
&lt;span class="py"&gt;show_milliseconds&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What it shows on every prompt:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Current directory&lt;/strong&gt; — truncated to fit&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Git branch and status&lt;/strong&gt; — dirty/clean, ahead/behind&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Python version&lt;/strong&gt; (if in a venv or project)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Command duration&lt;/strong&gt; — how long the last command took&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exit code&lt;/strong&gt; — red indicator on failure&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Dotfiles Repository
&lt;/h2&gt;

&lt;p&gt;Everything above lives in a single repo: &lt;a href="https://github.com/prateek11rai/dotfiles" rel="noopener noreferrer"&gt;&lt;code&gt;github.com/prateek11rai/dotfiles&lt;/code&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The structure mirrors &lt;code&gt;~/.config/&lt;/code&gt; directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dotfiles/
├── .zshrc
├── .config/
│   ├── starship.toml
│   ├── tmux/tmux.conf
│   ├── neofetch/
│   │   ├── config.conf
│   │   └── custom-ascii.txt
│   └── wezterm/
│       ├── wezterm.lua
│       ├── startup/init.lua
│       └── keybindings/init.lua
├── wallpapers/macos.png
├── scripts/mac-bootstrap.sh
└── README.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Bootstrap Script
&lt;/h3&gt;

&lt;p&gt;The script at &lt;code&gt;scripts/mac-bootstrap.sh&lt;/code&gt; automates setting up a new Mac:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Install Homebrew (if missing)&lt;/li&gt;
&lt;li&gt;Install packages: &lt;code&gt;tmux&lt;/code&gt;, &lt;code&gt;starship&lt;/code&gt;, &lt;code&gt;gh&lt;/code&gt;, &lt;code&gt;zsh-autosuggestions&lt;/code&gt;, &lt;code&gt;pyenv&lt;/code&gt;, &lt;code&gt;wezterm&lt;/code&gt;, &lt;code&gt;font-jetbrains-mono&lt;/code&gt;, &lt;code&gt;neofetch&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Back up any existing config files that are NOT already symlinks (renames to &lt;code&gt;.backup.YYYYMMDD-HHMMSS&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Symlink every config file from the repo into the correct &lt;code&gt;~/&lt;/code&gt; or &lt;code&gt;~/.config/&lt;/code&gt; location&lt;/li&gt;
&lt;li&gt;Install TPM and Tmux plugins&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Running it on a fresh Mac:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/prateek11rai/dotfiles.git ~/github/prateek11rai/dotfiles
~/github/prateek11rai/dotfiles/scripts/mac-bootstrap.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The backup step makes this safe to run on an already-configured machine — nothing gets lost.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNqFUlFPwyAQ_iuEB59cF18Xs4d1Tk1cNVKjGfWBtddBLNAAjTrjf_e6NsqiiTxcuI_v4Pvu-KClrYDO6M6JVpJ8URiCy3fbASioFuUtK-iA9-vxYsMfYZ-D0-dbN51f3j2QPlFGNM8_tHz98MRz3b0dSAy8V9aQtTBiBy7ibdgV33hJmIQmrmc5Z0E4L1VL7pzVbYgOsxXPwNYQSjlc_-4DaHJtajuywFTD5peh1Jpa7Y4c3fBX2Af0kDSdiD2kPKCDpMSSWHLKk72XrozlptyPcpNgdewkS7kZxU7_Vbe0oVYNeHIPrY1FLu_5TgXZbVGNnrZOBICXszMn1LQaa6I3F4zj4CZba4MPeHXi5d9PLxiZTObYAnKCdjFs-sD6kKUDBQc-co7zfsID0u8OEA5zQHBzAFh-nGerMU_HY3pKNTZeqAo_4UdBgwQNBZ1hKwx0KL0p6CeSurZCxxeVCtbRWS0aD6dUdMGyd1N-AwNrqQS2U9NZcB18fgGUN9v8" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNqFUlFPwyAQ_iuEB59cF18Xs4d1Tk1cNVKjGfWBtddBLNAAjTrjf_e6NsqiiTxcuI_v4Pvu-KClrYDO6M6JVpJ8URiCy3fbASioFuUtK-iA9-vxYsMfYZ-D0-dbN51f3j2QPlFGNM8_tHz98MRz3b0dSAy8V9aQtTBiBy7ibdgV33hJmIQmrmc5Z0E4L1VL7pzVbYgOsxXPwNYQSjlc_-4DaHJtajuywFTD5peh1Jpa7Y4c3fBX2Af0kDSdiD2kPKCDpMSSWHLKk72XrozlptyPcpNgdewkS7kZxU7_Vbe0oVYNeHIPrY1FLu_5TgXZbVGNnrZOBICXszMn1LQaa6I3F4zj4CZba4MPeHXi5d9PLxiZTObYAnKCdjFs-sD6kKUDBQc-co7zfsID0u8OEA5zQHBzAFh-nGerMU_HY3pKNTZeqAo_4UdBgwQNBZ1hKwx0KL0p6CeSurZCxxeVCtbRWS0aD6dUdMGyd1N-AwNrqQS2U9NZcB18fgGUN9v8" alt="Architecture diagram" width="1482" height="604"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Neofetch is Archived&lt;/strong&gt;&lt;br&gt;
Neofetch has been unmaintained since 2023. The bootstrap script falls back to downloading the raw script from GitHub if &lt;code&gt;brew install&lt;/code&gt; fails. Consider migrating to &lt;code&gt;fastfetch&lt;/code&gt; as a maintained replacement.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What It Looks Like
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyl36qnqk5ufgq4qewvpn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyl36qnqk5ufgq4qewvpn.png" alt="Franky building the staircase — creating something that takes you higher" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;WezTerm opens, attaches to the last Tmux session, and I see my Zsh prompt with the Dracula theme, Starship showing git status, and Neofetch greeting me with system info. Everything is the same as I left it — even if I closed the terminal an hour ago, on a different display, in a different city.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Philosophy
&lt;/h2&gt;

&lt;p&gt;Five principles that shaped this setup:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Tmux-first&lt;/strong&gt; — Tmux manages sessions, not the terminal. WezTerm is just a display layer. This separation means I can close the window, switch machines, or lose power without losing my place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One config repo&lt;/strong&gt; — All dotfiles in one place, one bootstrap script, one &lt;code&gt;git clone&lt;/code&gt; to set up a new machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minimal bootstrap&lt;/strong&gt; — Only Homebrew and git needed to go from zero to fully configured.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consistent theme&lt;/strong&gt; — Dracula everywhere. Same colors in the terminal, editor, and browser. Reduces visual friction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safe to re-run&lt;/strong&gt; — The bootstrap script backs up existing files before symlinking. I can run it on an already-configured machine without breaking anything.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every chef has their own knife kit, sharpened and arranged exactly how they like it. This is mine. WezTerm is the blade, Tmux is the workbench, Zsh is the cutting board, and Starship is the scale that tells you exactly what you are working with. The dotfiles repo is the roll that keeps it all together.&lt;/p&gt;




&lt;p&gt;Image credits and attribution — please &lt;a href="mailto:prateek11rai@protonmail.com"&gt;contact&lt;/a&gt; if anything needs updating.&lt;br&gt;
WezTerm has its own multiplexer, but Tmux is preferred because sessions persist when the window closes, it works identically over SSH, and it has decades of community tooling around it.&lt;/p&gt;

</description>
      <category>wezterm</category>
      <category>tmux</category>
      <category>terminal</category>
      <category>dotfiles</category>
    </item>
    <item>
      <title>Freeing the Screensaver: 4K Anime Art on Google TV</title>
      <dc:creator>Prateek Rai</dc:creator>
      <pubDate>Sat, 27 Jun 2026 19:26:34 +0000</pubDate>
      <link>https://dev.to/prateek11rai/freeing-the-screensaver-4k-anime-art-on-google-tv-di7</link>
      <guid>https://dev.to/prateek11rai/freeing-the-screensaver-4k-anime-art-on-google-tv-di7</guid>
      <description>&lt;p&gt;My TV spends more time idle than playing content. And every idle minute, Google TV shows me the same curated AI art or Google Photos memories — things I did not choose, do not control, and cannot change.&lt;/p&gt;

&lt;p&gt;I wanted my own screensaver. 4K anime art from Wallhaven, video loops from MoeWalls — playing on my TV, not streamed from a cloud service I never asked for.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz0ym0osl7uh3dap1jano.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz0ym0osl7uh3dap1jano.jpg" alt="Aerial Views screensaver on Google TV" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Since Android TV 12, Google TV devices have locked down the screensaver selection. The Settings UI no longer lists third-party screensaver apps. You get Ambient Mode — Google Photos, AI art, or a black screen. That is it.&lt;/p&gt;

&lt;p&gt;The change affected every Google TV device: Chromecast with Google TV, Google TV Streamer, and built-in Google TV on Sony, TCL, and Philips TVs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/theothernt/AerialViews" rel="noopener noreferrer"&gt;Aerial Views&lt;/a&gt; is an open-source screensaver app that supports 4K HDR videos, local photos, and media from USB/Samba/WebDAV. It is on the Play Store, but Google TV hides it from the launcher. You install it anyway via ADB and force the system to use it.&lt;/p&gt;

&lt;p&gt;The full pipeline:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNqFklFrwjAUhf9KyOt0dZuMIUPoaKeidcWKfUj3EJs7DWubkrS6If73pakbmQ4WCKTfPek995ADTgUDPMAbScstmi2SAuml6nULEhzQ9EmI9wS3lWa53hPRG-04RWsJ-1erFE6JC5LTDK047FUDrHLgexOX9KconI_QFQrCPnrmGaiTBArWHi5cjITYZICWK9vHZB4t3dmMRJxBJigDhtyytNpFy5eFTxzFUiqZ0_oythxL5C18NyBRKgEKRXcgUQRyx1P411QkapmCsi3FYxLTLNvq_xSPa-kM7x76vY_bm_teM7KdREwCAY1WGZ3ORKfxd8sm6253-D3vb2gibZE5GmgGP-nC6eXl04cpmPlbbK6dw3iMutdnjWIL4Q7OQeaUM_2MDgmutpBDggc6oALqStIswUctqktGK_AZr4TEgzeaKehgWlci-izSH9CqPE51yDkeVLKG4xdw48GU" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNqFklFrwjAUhf9KyOt0dZuMIUPoaKeidcWKfUj3EJs7DWubkrS6If73pakbmQ4WCKTfPek995ADTgUDPMAbScstmi2SAuml6nULEhzQ9EmI9wS3lWa53hPRG-04RWsJ-1erFE6JC5LTDK047FUDrHLgexOX9KconI_QFQrCPnrmGaiTBArWHi5cjITYZICWK9vHZB4t3dmMRJxBJigDhtyytNpFy5eFTxzFUiqZ0_oythxL5C18NyBRKgEKRXcgUQRyx1P411QkapmCsi3FYxLTLNvq_xSPa-kM7x76vY_bm_teM7KdREwCAY1WGZ3ORKfxd8sm6253-D3vb2gibZE5GmgGP-nC6eXl04cpmPlbbK6dw3iMutdnjWIL4Q7OQeaUM_2MDgmutpBDggc6oALqStIswUctqktGK_AZr4TEgzeaKehgWlci-izSH9CqPE51yDkeVLKG4xdw48GU" alt="Architecture diagram" width="1075" height="591"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Enable Developer Options on the TV
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Settings → Device Preferences → About → Build → click 7 times
Settings → System → Developer options → USB debugging → ON
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note down the TV's IP address: &lt;code&gt;Settings → Network → Wi-Fi → IP address&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Install ADB
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;android-platform-tools
adb version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Connect to the TV
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb connect &amp;lt;TV_IP&amp;gt;:5555
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the connection is refused (common on Android 14+ Chromecast with Google TV):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;strong&gt;Developer options → Wireless debugging&lt;/strong&gt; on the TV&lt;/li&gt;
&lt;li&gt;Select &lt;strong&gt;Pair device with pairing code&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Run the pair command shown on TV, then connect:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb pair &amp;lt;IP&amp;gt;:&amp;lt;PORT&amp;gt;   &lt;span class="c"&gt;# enter the 6-digit code&lt;/span&gt;
adb connect &amp;lt;IP&amp;gt;:&amp;lt;PORT&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Download and Install Aerial Views
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-LO&lt;/span&gt; https://github.com/theothernt/AerialViews/releases/download/1.8.2/aerial-views-1.8.2.apk
adb &lt;span class="nb"&gt;install &lt;/span&gt;aerial-views-1.8.2.apk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  5. Set It as the System Screensaver
&lt;/h3&gt;

&lt;p&gt;This is the critical step — it bypasses Google TV's UI lock:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb shell settings put secure screensaver_components &lt;span class="se"&gt;\&lt;/span&gt;
  com.neilturner.aerialviews/.ui.screensaver.DreamActivity

&lt;span class="c"&gt;# Verify&lt;/span&gt;
adb shell settings get secure screensaver_components
&lt;span class="c"&gt;# Expected: com.neilturner.aerialviews/.ui.screensaver.DreamActivity&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  6. Push Your Own Media
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Create a folder on the TV&lt;/span&gt;
adb shell &lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /sdcard/AerialViews

&lt;span class="c"&gt;# Push 4K images and videos&lt;/span&gt;
adb push ~/Downloads/wallhaven/&lt;span class="k"&gt;*&lt;/span&gt;.png /sdcard/AerialViews/
adb push ~/Downloads/moewall/&lt;span class="k"&gt;*&lt;/span&gt;.mp4 /sdcard/AerialViews/

&lt;span class="c"&gt;# Verify&lt;/span&gt;
adb shell find /sdcard/AerialViews &lt;span class="nt"&gt;-type&lt;/span&gt; f
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  7. Configure Aerial Views on the TV
&lt;/h3&gt;

&lt;p&gt;Open the Aerial Views app from the app drawer and configure:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Sources → Local storage&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Mode: &lt;strong&gt;Folder access&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Path: &lt;code&gt;/AerialViews&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Ensure only one volume is selected&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Playlist&lt;/strong&gt; — disable ALL online sources (Apple, Amazon, Jetson Creative, Robin Fourcade). Enable only &lt;strong&gt;Local storage&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Set &lt;strong&gt;Media type&lt;/strong&gt;: Mixed or Photos only&lt;/li&gt;
&lt;li&gt;Adjust &lt;strong&gt;Photo duration&lt;/strong&gt; to your preference&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Overlays&lt;/strong&gt; (optional) — clock, date, location&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Back → Test screensaver&lt;/strong&gt; to preview&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Where to Find 4K Anime Media
&lt;/h2&gt;

&lt;p&gt;Google TV is 16:9 4K, so aim for &lt;strong&gt;3840×2160&lt;/strong&gt; content:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Site&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://wallhaven.cc" rel="noopener noreferrer"&gt;Wallhaven&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Images&lt;/td&gt;
&lt;td&gt;4K PNG, best for anime art, bans AI art&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://desktophut.com" rel="noopener noreferrer"&gt;DesktopHut&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Video loops&lt;/td&gt;
&lt;td&gt;Free 4K MP4, anime/gaming/nature&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://moewalls.lat" rel="noopener noreferrer"&gt;MoeWalls&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Video loops&lt;/td&gt;
&lt;td&gt;Anime-focused 4K MP4/WEBM, free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://pixabay.com/videos/search/anime%20background/" rel="noopener noreferrer"&gt;Pixabay&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Videos&lt;/td&gt;
&lt;td&gt;15K+ free backgrounds, 4K, royalty-free&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The Current Setup
&lt;/h2&gt;

&lt;p&gt;My TV now cycles through a mix of 4K Wallhaven images and a video loop from MoeWalls — about 151 MB of local content that plays regardless of internet connectivity.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/sdcard/AerialViews/
├── wallhaven-je86dy_3840x2160.png
├── wallhaven-d86o9g_3840x2160.png
├── wallhaven-6llg7q_3840x2160.png
├── ...                # 8 more images
└── moewalls/
    └── cat-in-the-swamp-moewalls-com.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Quick Reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Connect&lt;/td&gt;
&lt;td&gt;&lt;code&gt;adb connect &amp;lt;IP&amp;gt;:5555&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pair (if needed)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;adb pair &amp;lt;IP&amp;gt;:&amp;lt;PORT&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Install APK&lt;/td&gt;
&lt;td&gt;&lt;code&gt;adb install app.apk&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Push files&lt;/td&gt;
&lt;td&gt;&lt;code&gt;adb push &amp;lt;local&amp;gt; &amp;lt;remote&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Set screensaver&lt;/td&gt;
&lt;td&gt;&lt;code&gt;adb shell settings put secure screensaver_components com.neilturner.aerialviews/.ui.screensaver.DreamActivity&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Restore default&lt;/td&gt;
&lt;td&gt;&lt;code&gt;adb shell settings put secure screensaver_components com.google.android.apps.tv.dreamx/.service.Backdrop&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change idle timeout&lt;/td&gt;
&lt;td&gt;&lt;code&gt;adb shell settings put system screen_off_timeout 300000&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fix took 15 minutes. Most of that was downloading wallpapers. The TV sits idle, and now it shows exactly what I want — no cloud, no AI, no algorithm.&lt;/p&gt;




&lt;p&gt;Image credits and attribution — please &lt;a href="mailto:prateek11rai@protonmail.com"&gt;contact&lt;/a&gt; if anything needs updating.&lt;/p&gt;

</description>
      <category>android</category>
      <category>homelab</category>
      <category>screensaver</category>
      <category>google</category>
    </item>
    <item>
      <title>Ohara Learns to Talk: Self-Hosting an AI Agent with Hermes</title>
      <dc:creator>Prateek Rai</dc:creator>
      <pubDate>Sat, 27 Jun 2026 19:26:24 +0000</pubDate>
      <link>https://dev.to/prateek11rai/ohara-learns-to-talk-self-hosting-an-ai-agent-with-hermes-mlj</link>
      <guid>https://dev.to/prateek11rai/ohara-learns-to-talk-self-hosting-an-ai-agent-with-hermes-mlj</guid>
      <description>&lt;p&gt;Ohara had been running quietly under my desk for weeks. Jellyfin streamed movies. Suwayomi downloaded manga chapters overnight. The server did its job without complaint, invisible and silent.&lt;/p&gt;

&lt;p&gt;But I wanted more. I wanted it to talk back.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdlac3oht09u5axr8f1pk.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdlac3oht09u5axr8f1pk.jpg" alt="Hermes AI Agent interface" width="800" height="137"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The idea was simple: an always-on AI agent I could message from my phone, anywhere in the world, running on my own hardware. No subscriptions, no cloud lock-in, no monthly fees for toys I half-use. Just a Telegram bot that could think, remember, and eventually do things.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Hermes
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://hermes-agent.nousresearch.com" rel="noopener noreferrer"&gt;Hermes Agent&lt;/a&gt; by Nous Research is an open-source AI agent framework designed to run on your own infrastructure. It connects to LLM providers (OpenRouter, OpenAI, Anthropic, local models) and messaging platforms (Telegram, WhatsApp, Discord) to become a persistent AI assistant.&lt;/p&gt;

&lt;p&gt;What sold me:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requirement&lt;/th&gt;
&lt;th&gt;Why Hermes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Runs on a Core 2 Duo&lt;/td&gt;
&lt;td&gt;No local GPU needed — all inference is API-based&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosted&lt;/td&gt;
&lt;td&gt;My data, my config, my keys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-platform&lt;/td&gt;
&lt;td&gt;One agent, many messaging backends&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extensible&lt;/td&gt;
&lt;td&gt;Skills, cron jobs, terminal access, memory&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The server's 15-year-old hardware does not matter here. Hermes does not run a model locally — it calls APIs. Ohara just needs to run a Docker container and have internet access. That is it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Docker Setup
&lt;/h2&gt;

&lt;p&gt;Hermes ships as an official Docker image, so it fits right into Ohara's existing infrastructure. I keep all app data under &lt;code&gt;/srv/appdata&lt;/code&gt;, so Hermes lives at &lt;code&gt;/srv/appdata/hermes/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/srv/appdata/hermes/
├── docker-compose.yml
├── .env
└── data/
    ├── .env
    ├── config.yaml
    ├── platforms/
    ├── sessions/
    ├── memories/
    └── skills/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  docker-compose.yml
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;hermes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nousresearch/hermes-agent:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hermes&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway run&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8642:8642"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/srv/appdata/hermes/data:/opt/data&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;OPENROUTER_API_KEY=${OPENROUTER_API_KEY}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;.env&lt;/code&gt; file beside the compose file holds just the OpenRouter key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;OPENROUTER_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sk-or-...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /srv/appdata/hermes
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Permission Trap
&lt;/h3&gt;

&lt;p&gt;On first boot, the &lt;code&gt;data/&lt;/code&gt; directory gets created as root. Hermes then drops privileges to UID 10000 (the &lt;code&gt;hermes&lt;/code&gt; user) inside the container and can no longer read its own files. You will know this happened because the container enters a restart loop with &lt;code&gt;PermissionError&lt;/code&gt; in the logs.&lt;/p&gt;

&lt;p&gt;The fix is simple — one-time, then it stays fixed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; 1000:1000 /srv/appdata/hermes/data
docker compose down &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the single most important thing to know about running Hermes in Docker. Every permission issue traces back to this. If you write files with &lt;code&gt;sudo tee&lt;/code&gt; or &lt;code&gt;docker cp&lt;/code&gt;, run that &lt;code&gt;chown&lt;/code&gt; command afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  OpenRouter: Free LLM Access
&lt;/h2&gt;

&lt;p&gt;Hermes needs an LLM to think. Running one locally on a Core 2 Duo with 4 GB RAM is not realistic. Instead, I route everything through &lt;a href="https://openrouter.ai" rel="noopener noreferrer"&gt;OpenRouter&lt;/a&gt; — a single API key that gives access to 200+ models from every provider.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Inside the container&lt;/span&gt;
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; hermes bash
&lt;span class="nb"&gt;source&lt;/span&gt; /opt/hermes/.venv/bin/activate
hermes model
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This launches an interactive wizard. Two choices:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Select &lt;strong&gt;OpenRouter&lt;/strong&gt; (the key is auto-picked from the &lt;code&gt;OPENROUTER_API_KEY&lt;/code&gt; env var)&lt;/li&gt;
&lt;li&gt;Pick a model&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The model I chose:&lt;/strong&gt; &lt;code&gt;nvidia/llama-3.1-nemotron-70b-instruct:free&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This is a NVIDIA-hosted Llama 3.1 variant available at no cost through OpenRouter's free tier.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNp1kEGLwkAMhf9KyNnSexFBdNcK2kotvbR7iDbagXampNPCIv73Ha24LrubU_LywhfeBY-mZAzwLNRWsEkKDa66_jAKBcYVCcGeZWApcFzfKsxDloY7WBhtSWmWj-_lInfqSZ3B72TwqW1LsuRX94OHjXU5Nr-Im80WdmIGVf4kxkket6wT01sWmO_WL8Aoy6NsvVzPIeLGWDF6ehB_9i7MkKrnb_9DF7VibV9x6SpPuWZnaP6-Tlcw9TzYctfRmcHzZhCOm_A-xI8w4-Q-RhlOsHERkCpd4JcCbcUNFxg4vObeCtUFXp2pv8XFb6WyRjA4Ud3xBKm3Zv-pj09hdC0V3T7EwErP1y-FiI14" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNp1kEGLwkAMhf9KyNnSexFBdNcK2kotvbR7iDbagXampNPCIv73Ha24LrubU_LywhfeBY-mZAzwLNRWsEkKDa66_jAKBcYVCcGeZWApcFzfKsxDloY7WBhtSWmWj-_lInfqSZ3B72TwqW1LsuRX94OHjXU5Nr-Im80WdmIGVf4kxkket6wT01sWmO_WL8Aoy6NsvVzPIeLGWDF6ehB_9i7MkKrnb_9DF7VibV9x6SpPuWZnaP6-Tlcw9TzYctfRmcHzZhCOm_A-xI8w4-Q-RhlOsHERkCpd4JcCbcUNFxg4vObeCtUFXp2pv8XFb6WyRjA4Ud3xBKm3Zv-pj09hdC0V3T7EwErP1y-FiI14" alt="Architecture diagram" width="1127" height="256"&gt;&lt;/a&gt;&lt;br&gt;
The key insight: &lt;strong&gt;the API route means your server's hardware does not matter.&lt;/strong&gt; A Raspberry Pi would work just as well. The server just needs to make HTTP requests and relay responses. No GPU, no RAM for model weights, no quantization — just a network connection.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Rate Limit Reality
&lt;/h2&gt;

&lt;p&gt;Here is where the free tier fantasy meets reality. A brand-new OpenRouter key gets &lt;strong&gt;50 free requests per day&lt;/strong&gt;. That sounds like a lot until you realize each Hermes conversation turn calls the model &lt;strong&gt;3-5 times&lt;/strong&gt; — one for thinking, one for tool calls, one for the final response. Those 50 requests become 10-15 usable conversations before the key hits the wall.&lt;/p&gt;

&lt;p&gt;You know you have hit the limit when Hermes stops responding and OpenRouter returns a &lt;code&gt;429&lt;/code&gt; or a vague gateway error:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0yki0b27md9g24oxnl69.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0yki0b27md9g24oxnl69.jpg" alt="Rate limit error on OpenRouter" width="800" height="226"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fix is unsurprising:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Add a minimum of &lt;strong&gt;$10&lt;/strong&gt; in credits to your OpenRouter account. This unlocks &lt;strong&gt;1000 requests/day&lt;/strong&gt; — more than enough for a personal bot.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;$10 on OpenRouter lasts months for a single-user bot. The free tier is a generous trial — the paid tier is where it becomes actually useful. And $10 is still cheaper than a month of any subscription service.&lt;/p&gt;
&lt;h2&gt;
  
  
  Telegram Integration
&lt;/h2&gt;

&lt;p&gt;The messaging layer is what makes this useful. A web UI is fine for configuration, but the real power is messaging your server like a person.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 1: Create the Bot
&lt;/h3&gt;

&lt;p&gt;Open Telegram, search for &lt;a href="https://t.me/BotFather" rel="noopener noreferrer"&gt;@BotFather&lt;/a&gt;, and send &lt;code&gt;/newbot&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pick a name (I used &lt;code&gt;Ohara Hermes&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Pick a username ending in &lt;code&gt;_bot&lt;/code&gt; (e.g. &lt;code&gt;ohara_hermes_bot&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;BotFather replies with a token: &lt;code&gt;YOUR_BOT_TOKEN&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;
  
  
  Step 2: Get Your User ID
&lt;/h3&gt;

&lt;p&gt;Telegram bots need to know who is allowed to talk to them. Find your numeric user ID by messaging &lt;a href="https://t.me/userinfobot" rel="noopener noreferrer"&gt;@userinfobot&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 3: Configure Hermes
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'TELEGRAM_ENABLED=true'&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /srv/appdata/hermes/data/.env
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"TELEGRAM_BOT_TOKEN=YOUR_BOT_TOKEN"&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /srv/appdata/hermes/data/.env
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'TELEGRAM_ALLOWED_USERS=8523398033'&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /srv/appdata/hermes/data/.env
&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; 1000:1000 /srv/appdata/hermes/data
docker restart hermes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Environment variable name&lt;/strong&gt;&lt;br&gt;
The correct variable is &lt;code&gt;TELEGRAM_ALLOWED_USERS&lt;/code&gt;. Using &lt;code&gt;TELEGRAM_ALLOWED_USER_IDS&lt;/code&gt; (which some older docs reference) will cause "Unauthorized user" errors in the logs and the bot will reject every message.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  Step 4: Test It
&lt;/h3&gt;

&lt;p&gt;Open Telegram, find your bot by username, send a message. The agent responds. That is the whole setup — four commands and you have an AI assistant on your phone.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc91pyi9jkoguzu8o4ajd.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc91pyi9jkoguzu8o4ajd.jpg" alt="Telegram conversation with Hermes bot" width="800" height="652"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The WhatsApp Detour
&lt;/h2&gt;

&lt;p&gt;Hermes also supports WhatsApp through a Baileys-based bridge (emulates WhatsApp Web, no Business API needed). I tried it. I removed it.&lt;/p&gt;

&lt;p&gt;The self-chat mode works in theory — you message yourself, Hermes replies in the same thread. In practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The bridge rejected messages with &lt;code&gt;self_chat_mode_rejects_non_self&lt;/code&gt; even though the sender was the paired number&lt;/li&gt;
&lt;li&gt;Permission conflicts between the bridge log (owned by root) and the gateway&lt;/li&gt;
&lt;li&gt;The user experience is worse than Telegram — no voice messages, limited formatting, and the self-chat thread gets confusing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Removal is clean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Inside container&lt;/span&gt;
&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'/^WHATSAPP/d'&lt;/span&gt; /opt/data/.env
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /opt/data/platforms/whatsapp
docker restart hermes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnpfpwwzgt1rhvgyycriw.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnpfpwwzgt1rhvgyycriw.jpg" alt="WhatsApp self-chat with Hermes bot" width="800" height="1657"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Telegram is the first-class messaging platform for Hermes. WhatsApp works but has rough edges I did not want to maintain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Permission Pattern (It Keeps Happening)
&lt;/h2&gt;

&lt;p&gt;Every Hermes issue I ran into traced back to the same root cause: &lt;strong&gt;file ownership&lt;/strong&gt;. Here is the complete list of symptoms and the universal fix:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Container restart loop&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;data/&lt;/code&gt; owned by root, container UID 10000 can't write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PermissionError: .bundled_manifest&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Skills file owned by root&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;I have no name!&lt;/code&gt; shell prompt&lt;/td&gt;
&lt;td&gt;Cosmetic — UID 10000 not in /etc/passwd&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Telegram unauthorized&lt;/td&gt;
&lt;td&gt;Wrong env var name (&lt;code&gt;_USER_IDS&lt;/code&gt; vs &lt;code&gt;_USERS&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.env&lt;/code&gt; changes not picked up&lt;/td&gt;
&lt;td&gt;File written with sudo, owned by root&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fix every time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; 1000:1000 /srv/appdata/hermes/data
docker restart hermes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A DNS Detour: When Tailscale Broke GitHub Pages
&lt;/h2&gt;

&lt;p&gt;This one was unexpected. After setting up Hermes, I tried to visit my GitHub Pages site (&lt;code&gt;https://prateek11rai.github.io/sanji/&lt;/code&gt;) to check the previous blog post. The browser showed nothing. DNS lookup failed. But everything else on the internet worked fine.&lt;/p&gt;

&lt;p&gt;The culprit was hiding in plain sight:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;dig prateek11rai.github.io

&lt;span class="p"&gt;;;&lt;/span&gt; SERVER: 100.100.100.100#53  &lt;span class="c"&gt;# ← Tailscale DNS&lt;/span&gt;
&lt;span class="p"&gt;;;&lt;/span&gt; status: SERVFAIL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tailscale's MagicDNS was intercepting the &lt;code&gt;.github.io&lt;/code&gt; lookup and failing to resolve it. The server at &lt;code&gt;100.100.100.100&lt;/code&gt; — Tailscale's built-in DNS resolver — did not know how to handle GitHub Pages domains.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Temporarily disable Tailscale&lt;/strong&gt; — the site loads immediately through the normal DNS resolver. Alternatively, add a secondary DNS server (like &lt;code&gt;8.8.8.8&lt;/code&gt;) in your system network settings while Tailscale is connected.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is a quirk of how Tailscale handles DNS on macOS. It overrides your system DNS with its own resolver, and that resolver does not forward non-tailnet domains correctly in all configurations. The &lt;code&gt;.github.io&lt;/code&gt; namespace is a GitHub-controlled TLD that Tailscale's DNS does not recognize.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Complete Architecture
&lt;/h2&gt;

&lt;p&gt;Here is what Ohara looks like now:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNqFU99v2jAQ_lcsP-xlMKapYxuqKoVRCkgBlGTpg-nDkRzBIrGR7dCiqv_7LpiuqToJPzi-7777kfPnZ57pHPmAFwb2W5YMV4rRsvXaAys-0RWyObpHbXYr7t3NikSka4fmgXW7NywWiy0YYDGaA5rrtend_FnXytXsMxvpbEc8H4oq94cPhTyN_dbKgVRobLvabCxmWJbHjVSn5EttHPv59Vf_4Y0T34u4foSjruQb5-p7_0eLM4nEBE2FlgUFKtfK1b_6drHF2GkDBbYbC0XPmkOvwlzCKVuoDxJthyVph4W1lRl9QBXQa3UR-CDY73NwPox-eyMLS-MK0UED9y62c_tE81dQsmA5fTetRSQWe1T-ghpvq_Y8FfN0OpoGdKmVdkb7gY4NIgtJC-XFsiFaC4VURbtkcicSLJE41cUECcjSZlBSQbTbdpbpUsjlVitsNZykIlC50TKnYwsPhyKEbKj17v8F45Mwvao8clZYA8_G7BMJhrZJ5J2ENI7wHHzftl7ZDRR4aBKdrMU5euHNeerN5I5ddxvgNft0SRmSlLZwyLpfmhfDO7wiKYLM6fk9r7jbYkXSGtCAFNbOQLniL0SqG5HgbS5JfHywgdJih0PtdHxU2T_As0YSmhvgA2dqfPkLjkEilw" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNqFU99v2jAQ_lcsP-xlMKapYxuqKoVRCkgBlGTpg-nDkRzBIrGR7dCiqv_7LpiuqToJPzi-7777kfPnZ57pHPmAFwb2W5YMV4rRsvXaAys-0RWyObpHbXYr7t3NikSka4fmgXW7NywWiy0YYDGaA5rrtend_FnXytXsMxvpbEc8H4oq94cPhTyN_dbKgVRobLvabCxmWJbHjVSn5EttHPv59Vf_4Y0T34u4foSjruQb5-p7_0eLM4nEBE2FlgUFKtfK1b_6drHF2GkDBbYbC0XPmkOvwlzCKVuoDxJthyVph4W1lRl9QBXQa3UR-CDY73NwPox-eyMLS-MK0UED9y62c_tE81dQsmA5fTetRSQWe1T-ghpvq_Y8FfN0OpoGdKmVdkb7gY4NIgtJC-XFsiFaC4VURbtkcicSLJE41cUECcjSZlBSQbTbdpbpUsjlVitsNZykIlC50TKnYwsPhyKEbKj17v8F45Mwvao8clZYA8_G7BMJhrZJ5J2ENI7wHHzftl7ZDRR4aBKdrMU5euHNeerN5I5ddxvgNft0SRmSlLZwyLpfmhfDO7wiKYLM6fk9r7jbYkXSGtCAFNbOQLniL0SqG5HgbS5JfHywgdJih0PtdHxU2T_As0YSmhvgA2dqfPkLjkEilw" alt="Architecture diagram" width="1019" height="896"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Comes Next
&lt;/h2&gt;

&lt;p&gt;Hermes has a built-in cron scheduler. I have started experimenting with daily prompts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;hermes cron add &lt;span class="nt"&gt;--schedule&lt;/span&gt; &lt;span class="s2"&gt;"0 6 * * *"&lt;/span&gt; &lt;span class="nt"&gt;--prompt&lt;/span&gt; &lt;span class="s2"&gt;"Check my Jellyfin library and recommend something to watch tonight"&lt;/span&gt; &lt;span class="nt"&gt;--platform&lt;/span&gt; telegram
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The longer-term plan is a &lt;strong&gt;coding agent&lt;/strong&gt; — Hermes that can clone a repo, run Claude Code through OpenRouter, make changes, commit, and create a PR, all triggered from a Telegram message. The infrastructure is ready: Hermes has terminal access, file system access, and network connectivity. It just needs the right skills configured.&lt;/p&gt;

&lt;p&gt;But that is a post for another day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things I Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Permission issues are the #1 cause of Docker container crashes&lt;/strong&gt; — any file written as root inside a volume will eventually break something. &lt;code&gt;chown 1000:1000&lt;/code&gt; after every file write and move on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API-based AI does not need a GPU&lt;/strong&gt; — a 15-year-old Core 2 Duo with 4 GB RAM is enough to run a full AI agent. The only requirement is internet access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenRouter free tier is a trial, not a plan&lt;/strong&gt; — 50 requests/day sounds fine until Hermes burns 3-5 per turn. Budget $10 to unlock 1000/day. It lasts months for a personal bot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailscale's DNS can interfere with non-tailnet domains&lt;/strong&gt; — &lt;code&gt;.github.io&lt;/code&gt;, and potentially other TLDs, may fail to resolve through Tailscale's MagicDNS. Know how to bypass it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Telegram is the better platform&lt;/strong&gt; — WhatsApp integration exists but Telegram's bot API is cleaner, more reliable, and better documented. Start with Telegram.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fedy12x0zuccda8hsl071.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fedy12x0zuccda8hsl071.jpg" alt="Dr. Vegapunk — the mind behind the future" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The server under my desk still runs Jellyfin and Suwayomi. But now it also talks back. When I send a message to &lt;code&gt;@ohara_hermes_bot&lt;/code&gt;, a container on that dusty old OptiPlex wakes up, calls a free NVIDIA-hosted model through OpenRouter, thinks about what I asked, and replies. No cloud subscription. No exposed ports. No GPU required.&lt;/p&gt;

&lt;p&gt;That is the magic of self-hosting in 2026. The hardware you already own is enough.&lt;/p&gt;




&lt;p&gt;Image attribution and credits — please &lt;a href="mailto:prateek11rai@protonmail.com"&gt;contact&lt;/a&gt; if anything needs updating.&lt;/p&gt;

</description>
      <category>selfhosting</category>
      <category>ai</category>
      <category>llm</category>
      <category>hermes</category>
    </item>
    <item>
      <title>Breathing Life Into an Old PC: Building a Self-Hosted Media &amp; Manga Server</title>
      <dc:creator>Prateek Rai</dc:creator>
      <pubDate>Sat, 27 Jun 2026 19:01:44 +0000</pubDate>
      <link>https://dev.to/prateek11rai/breathing-life-into-an-old-pc-building-a-self-hosted-media-manga-server-16a5</link>
      <guid>https://dev.to/prateek11rai/breathing-life-into-an-old-pc-building-a-self-hosted-media-manga-server-16a5</guid>
      <description>&lt;p&gt;I had an old PC lying around — one of those machines you keep meaning to throw out but never do. It was slow, loud, and completely useless for modern computing. No one was going to browse the web on it, let alone run any real software.&lt;/p&gt;

&lt;p&gt;Then it hit me: I could turn it into a server.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzln5yokgticgfk7d39mv.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzln5yokgticgfk7d39mv.jpg" alt="Old PC repurposed as a server" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the story of how that dusty old tower became the backbone of my home media setup. I call it &lt;strong&gt;Ohara&lt;/strong&gt; — after the island of scholars in &lt;em&gt;One Piece&lt;/em&gt; — because it preserves and serves knowledge (and manga, and movies, and music) to every device in my house.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Hardware: What I Was Working With
&lt;/h2&gt;

&lt;p&gt;Before you go out and buy a Raspberry Pi or a NAS, look under your desk. Chances are you already own a server.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;🖥️ &lt;strong&gt;The Specs&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Old Dell OptiPlex office PC&lt;/li&gt;
&lt;li&gt;Intel Core 2 Duo (~15 years old)&lt;/li&gt;
&lt;li&gt;4 GB DDR3 RAM&lt;/li&gt;
&lt;li&gt;NVIDIA GeForce 8400 GS (2007-era, useless for AI)&lt;/li&gt;
&lt;li&gt;500 GB HDD + external USB drives&lt;/li&gt;
&lt;li&gt;Gigabit Ethernet port&lt;/li&gt;
&lt;li&gt;No monitor, no keyboard, no mouse&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;✅ &lt;strong&gt;What it can do&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stream 1080p media to multiple devices ✔️&lt;/li&gt;
&lt;li&gt;Self-host a manga server with auto-downloads ✔️&lt;/li&gt;
&lt;li&gt;Run 24/7 on ~40W power ✔️&lt;/li&gt;
&lt;li&gt;Serve files over a private encrypted network ✔️&lt;/li&gt;
&lt;li&gt;Run LLMs or AI workloads ❌&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNptUcFOAjEQ_ZVJz2AI4mUPJkoJkqgYupy6HIbdARp2W1JajSH8u4MluhudS-e9N8m8Nz2J0lUkMrH1eNhBLgsLXMe4TkQh5jv0CIr8O_lCJPlS47elntlANYydJxiCjG71Ky8eXvQIpo8g5eK2xT9Jqe8Gg4vCbUt4nY311GxxbQJMwo68pXCVyVap-WNOuU34QF6vApb7tr250st1tCFercNwdDMYwXOuWjulK_fkdXpgYrfGUkvOlc7R1McSa_rfCh8B-v173pYwp-5gjtjBHLKD5-obJgMdKleiJxryDZqKv-dUCL5JQ4XIOLalGDzWhTjzUDxUGGhSmeC8yDZYH6knMAanPm35Q6QpaZBP14gs-EjnL9u4k2o" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNptUcFOAjEQ_ZVJz2AI4mUPJkoJkqgYupy6HIbdARp2W1JajSH8u4MluhudS-e9N8m8Nz2J0lUkMrH1eNhBLgsLXMe4TkQh5jv0CIr8O_lCJPlS47elntlANYydJxiCjG71Ky8eXvQIpo8g5eK2xT9Jqe8Gg4vCbUt4nY311GxxbQJMwo68pXCVyVap-WNOuU34QF6vApb7tr250st1tCFercNwdDMYwXOuWjulK_fkdXpgYrfGUkvOlc7R1McSa_rfCh8B-v173pYwp-5gjtjBHLKD5-obJgMdKleiJxryDZqKv-dUCL5JQ4XIOLalGDzWhTjzUDxUGGhSmeC8yDZYH6knMAanPm35Q6QpaZBP14gs-EjnL9u4k2o" alt="Architecture diagram" width="886" height="378"&gt;&lt;/a&gt;&lt;br&gt;
The GPU is so old it predates CUDA entirely. But here is the thing: &lt;strong&gt;media serving is not GPU-bound.&lt;/strong&gt; Jellyfin can hardware-transcode if you have a modern GPU, but for direct-play or software transcoding of 1080p content, even this Core 2 Duo handles it fine. The real bottleneck is always the network, not the CPU.&lt;/p&gt;
&lt;h2&gt;
  
  
  The OS Choice: Ubuntu Server
&lt;/h2&gt;

&lt;p&gt;I went with &lt;strong&gt;Ubuntu Server 24.04 LTS&lt;/strong&gt; — minimal install, no desktop environment. The alternatives were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Debian&lt;/strong&gt; — lighter, but hardware detection is worse and you lose out on broader community support&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alpine&lt;/strong&gt; — very light, but musl libc can cause Docker compatibility surprises&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proxmox&lt;/strong&gt; — overkill for a single machine, eats RAM for the hypervisor layer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ubuntu Server wins because when something breaks, someone has already asked and answered the question on the internet.&lt;/p&gt;
&lt;h3&gt;
  
  
  Installation Steps
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Download the Ubuntu Server ISO via &lt;a href="https://ubuntu.com/download/server" rel="noopener noreferrer"&gt;torrent&lt;/a&gt; — browser downloads are slow, magnets are faster&lt;/li&gt;
&lt;li&gt;Create a bootable USB with &lt;a href="https://rufus.ie" rel="noopener noreferrer"&gt;Rufus&lt;/a&gt; (Windows) or &lt;code&gt;dd&lt;/code&gt; (Mac/Linux)&lt;/li&gt;
&lt;li&gt;Boot the old PC from the USB, install Ubuntu Server — &lt;strong&gt;do not install a GUI&lt;/strong&gt;, select "Install Ubuntu Server" and keep it minimal&lt;/li&gt;
&lt;li&gt;During install, enable SSH and create your user account&lt;/li&gt;
&lt;li&gt;After first boot:
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; docker.io docker-compose-v2 ufw
&lt;span class="nb"&gt;sudo &lt;/span&gt;snap remove lxd  &lt;span class="c"&gt;# remove unnecessary bloat&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Running It Headless
&lt;/h2&gt;

&lt;p&gt;The PC sits under my desk with nothing plugged in except power and ethernet. No monitor, no keyboard. Everything is done over SSH.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNp1UU1LAzEQ_StDTgptbYuK5lAUKygULfbQy17GZGyX7iZxMlGk9L-bkrZCizmE8OZ9zGTWynhLSqtIn4mcoXGNC8a2cpBPQJba1AGdwASD-HCKv_kkxKf4jPhrj5e7OHRHo1LSMGWKEYL_Job3JOIdnHmG-eskO5kVyXkRFn4WliwN46eHKfC24yhQpeHgdggLkggIk_sXeJ4eBe51rsUA3eggC3qD65veoNe_GF4WdiFldpHpXWx2A1tH4_Ob7D-TxLiEwChEq7s_76v-Uf8H5yU1DaAx2_nzdzshqzqqJW6xtnkb60rJklqqlIZKOUrC2FRqk0kp2JzzaGvxrPQHNpE6CpP42Y8zB6CwdstUWjjR5he0zqLR" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNp1UU1LAzEQ_StDTgptbYuK5lAUKygULfbQy17GZGyX7iZxMlGk9L-bkrZCizmE8OZ9zGTWynhLSqtIn4mcoXGNC8a2cpBPQJba1AGdwASD-HCKv_kkxKf4jPhrj5e7OHRHo1LSMGWKEYL_Job3JOIdnHmG-eskO5kVyXkRFn4WliwN46eHKfC24yhQpeHgdggLkggIk_sXeJ4eBe51rsUA3eggC3qD65veoNe_GF4WdiFldpHpXWx2A1tH4_Ob7D-TxLiEwChEq7s_76v-Uf8H5yU1DaAx2_nzdzshqzqqJW6xtnkb60rJklqqlIZKOUrC2FRqk0kp2JzzaGvxrPQHNpE6CpP42Y8zB6CwdstUWjjR5he0zqLR" alt="Architecture diagram" width="758" height="445"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Find the server on your network&lt;/span&gt;
nmap &lt;span class="nt"&gt;-sn&lt;/span&gt; 192.168.1.0/24

&lt;span class="c"&gt;# SSH in (use the IP you found)&lt;/span&gt;
ssh prateek@192.168.1.50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Making Sure It Never Sleeps
&lt;/h3&gt;

&lt;p&gt;Servers should run 24/7. The cleanest way to disable sleep on Ubuntu Server is through &lt;code&gt;logind&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/systemd/logind.conf.d
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/systemd/logind.conf.d/nosleep.conf &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
[Login]
HandleSuspendKey=ignore
HandleHibernateKey=ignore
HandleLidSwitch=ignore
HandleLidSwitchExternalPower=ignore
IdleAction=ignore
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart systemd-logind
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Storage Layout
&lt;/h2&gt;

&lt;p&gt;All data lives under &lt;code&gt;/srv&lt;/code&gt;, following the Linux Filesystem Hierarchy Standard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/srv/
├── media/
│   ├── movies/
│   ├── tv/
│   ├── music/
│   ├── music-videos/
│   └── manga/
└── appdata/
    ├── jellyfin/
    └── suwayomi/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The separation is deliberate: &lt;code&gt;/srv/media&lt;/code&gt; holds the actual content (large, slow-changing), while &lt;code&gt;/srv/appdata&lt;/code&gt; holds Docker configs and databases (small, needs backup). You can back up configs independently of media this way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tailscale: The Networking Layer
&lt;/h2&gt;

&lt;p&gt;I never expose any port to the public internet. Instead, everything is accessed through &lt;a href="https://tailscale.com" rel="noopener noreferrer"&gt;Tailscale&lt;/a&gt; — a WireGuard-based mesh VPN that makes all my devices think they are on the same local network.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNpV0MtqwzAQBdBfEbNqIbRJHxtTFxraXU0NNvGiymJiTWJRWzKy1Ach_94pgtQagdAdndU9QmsVQQYHh2Mn6rU0gmcKu7iQUKPuDXkJ8edvqncJbx06FBcVuU9ylw87d_24Wi6vvvmsJGz_ccG4wHZt7UeibhJVstJlZw0l6DZB9YbVk1HOasUhkXeJbBg22ij7NYlXHL0dE3x_xmRUfMS7Enmei2IeynmoN_PUwAIGcgNqxQ0eJfiOBpKQcW2GgnfYSzgxCqNCTy9Ke-sg22M_0QIweFv9mPa8iOpZI1c_QOZdoNMv0NF4wQ" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNpV0MtqwzAQBdBfEbNqIbRJHxtTFxraXU0NNvGiymJiTWJRWzKy1Ach_94pgtQagdAdndU9QmsVQQYHh2Mn6rU0gmcKu7iQUKPuDXkJ8edvqncJbx06FBcVuU9ylw87d_24Wi6vvvmsJGz_ccG4wHZt7UeibhJVstJlZw0l6DZB9YbVk1HOasUhkXeJbBg22ij7NYlXHL0dE3x_xmRUfMS7Enmei2IeynmoN_PUwAIGcgNqxQ0eJfiOBpKQcW2GgnfYSzgxCqNCTy9Ke-sg22M_0QIweFv9mPa8iOpZI1c_QOZdoNMv0NF4wQ" alt="Architecture diagram" width="511" height="548"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# On the server&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://tailscale.com/install.sh | sh
&lt;span class="nb"&gt;sudo &lt;/span&gt;tailscale up
&lt;span class="c"&gt;# Opens a URL — authenticate once, done forever&lt;/span&gt;

&lt;span class="c"&gt;# On Mac&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;tailscale &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; open /Applications/Tailscale.app

&lt;span class="c"&gt;# On iPhone / Android TV&lt;/span&gt;
&lt;span class="c"&gt;# Install from App Store / Play Store, log in&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once Tailscale is running, every device gets a &lt;code&gt;100.x.x.x&lt;/code&gt; IP. I reach my server by its hostname from anywhere in the world — no DDNS, no port forwarding, no Cloudflare tunnels. It survives reboots automatically. If the server restarts, Tailscale reconnects without manual intervention.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verification Checklist
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;Expected&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Device visibility&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tailscale status&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;All 4-5 devices listed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network connectivity&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ping 100.x.x.x&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Replies from server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSH over mesh&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ssh ohara&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Login prompt from anywhere&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;File transfer&lt;/td&gt;
&lt;td&gt;&lt;code&gt;rsync -avz test.txt ohara:/tmp/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;File arrives&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Docker Compose: The Infrastructure as Code
&lt;/h2&gt;

&lt;p&gt;Every service runs in a container defined by a &lt;code&gt;docker-compose.yml&lt;/code&gt;. The entire server can be rebuilt from these files in minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Main Compose File
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# /srv/appdata/docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;jellyfin&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jellyfin/jellyfin:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jellyfin&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8096:8096"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/srv/appdata/jellyfin:/config&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/srv/media:/media:ro&lt;/span&gt;
    &lt;span class="na"&gt;devices&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/dev/dri:/dev/dri&lt;/span&gt;  &lt;span class="c1"&gt;# hardware transcoding (if available)&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Suwayomi: The Manga Server
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/Suwayomi/Suwayomi-Server" rel="noopener noreferrer"&gt;Suwayomi&lt;/a&gt; is a self-hosted manga reader server. It is a rewrite of &lt;strong&gt;Tachiyomi&lt;/strong&gt; (the popular Android manga reader) for the server — it downloads chapters, stores them on disk, and serves them to clients over the web. The workflow is simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Browse and add manga from your phone via the WebUI&lt;/li&gt;
&lt;li&gt;Suwayomi downloads the chapters to &lt;code&gt;/srv/media/manga/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Read from any device — progress syncs automatically&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Docker Compose for Suwayomi lives alongside Jellyfin in my setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Suwayomi service — add to /srv/appdata/docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;suwayomi&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/suwayomi/suwayomi-server:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;suwayomi&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4567:4567"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SUWAYOMI_USER=prateek&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SUWAYOMI_PASSWORD=your_secure_password_here&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/srv/appdata/suwayomi:/home/suwayomi/.local/share/Suwayomi&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/srv/media/manga:/manga&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /srv/appdata
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; suwayomi

&lt;span class="c"&gt;# Check it's running&lt;/span&gt;
docker compose ps

&lt;span class="c"&gt;# Watch logs&lt;/span&gt;
docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; suwayomi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Adding Manga Sources (Extensions)
&lt;/h4&gt;

&lt;p&gt;Suwayomi is compatible with the &lt;strong&gt;Tachiyomi/Mihon extension ecosystem&lt;/strong&gt;, but the way you add sources is different from the Android app. Extensions are added from &lt;em&gt;inside&lt;/em&gt; the Suwayomi WebUI, not from the Docker config. This separation is intentional — infrastructure stays clean, and application config stays flexible.&lt;/p&gt;

&lt;p&gt;Here is the important backstory. The original &lt;strong&gt;Tachiyomi&lt;/strong&gt; project was discontinued. The community forked it into &lt;strong&gt;Mihon&lt;/strong&gt; (the Android reader), and the extension ecosystem moved to &lt;strong&gt;Keiyoushi&lt;/strong&gt; — a community-maintained repository of manga sources. Suwayomi can use this same repository.&lt;/p&gt;

&lt;p&gt;I have both repos starred on GitHub:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Repo&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/Suwayomi/Suwayomi-Server" rel="noopener noreferrer"&gt;&lt;code&gt;Suwayomi/Suwayomi-Server&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;The server itself — a rewrite of Tachiyomi for desktop/server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/keiyoushi/extensions" rel="noopener noreferrer"&gt;&lt;code&gt;keiyoushi/extensions&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Extension repository for Mihon and Suwayomi — sources like MangaDex, MangaSee, etc.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;To add sources to Suwayomi:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Step 1: Open the WebUI&lt;/span&gt;
&lt;span class="c"&gt;# In your browser (over Tailscale):&lt;/span&gt;
&lt;span class="c"&gt;# http://ohara:4567&lt;/span&gt;
&lt;span class="c"&gt;# or http://100.x.x.x:4567&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Log in with the credentials you set in &lt;code&gt;docker-compose.yml&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Go to &lt;strong&gt;Extensions&lt;/strong&gt; → &lt;strong&gt;Repositories&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Add the Keiyoushi extensions repository URL:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   https://keiyoushi.github.io/extensions/index.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Click &lt;strong&gt;Save&lt;/strong&gt; — the repository is now registered&lt;/li&gt;
&lt;li&gt;Go to &lt;strong&gt;Extensions&lt;/strong&gt; → &lt;strong&gt;All&lt;/strong&gt;, refresh the list&lt;/li&gt;
&lt;li&gt;You should now see sources like &lt;strong&gt;MangaDex&lt;/strong&gt;, &lt;strong&gt;MangaSee&lt;/strong&gt;, &lt;strong&gt;Comick&lt;/strong&gt; available for install&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Install&lt;/strong&gt; on each source you want&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNqFUk1rwkAQ_SvDniwYcw_Fy8ZCsUJxWxSSHsZkNEuT3bAfVhH_e1fT2oQWOqeZ994Mb2b3xApdEkvYzmBbwdMyVxDC-k0H5Ez4DzzqRuasoy7BM0FmTwa4Vg6lIvP2Q4psFOCt3N1vTDyNrdnH2LYlOozt16y7nnyRjRaodggPsib709NQKTFuLtS3nFTZJb9M8lqScrbvcZUJ3KKRsKLN6yNEIJ8rrejfUUJ7U5CFvUSYkzxqb6vB8vPsBl_dzg6OlJVaWVhSq_urpdl1tZQOfVR0qCDqoXyd8XCZ4v1vgxyiaAqiXyy6YgXRBF5Q1rbAmmASGN4x86uMD3rSQTWYx9dszBoyDcoyfIhTzlxFDeUsCVdR5J3BOmfnIPKX16RZKZ02LNlibWnM0Dstjqq4AZ0qlRgu27DEGU_nT6sJurc" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNqFUk1rwkAQ_SvDniwYcw_Fy8ZCsUJxWxSSHsZkNEuT3bAfVhH_e1fT2oQWOqeZ994Mb2b3xApdEkvYzmBbwdMyVxDC-k0H5Ez4DzzqRuasoy7BM0FmTwa4Vg6lIvP2Q4psFOCt3N1vTDyNrdnH2LYlOozt16y7nnyRjRaodggPsib709NQKTFuLtS3nFTZJb9M8lqScrbvcZUJ3KKRsKLN6yNEIJ8rrejfUUJ7U5CFvUSYkzxqb6vB8vPsBl_dzg6OlJVaWVhSq_urpdl1tZQOfVR0qCDqoXyd8XCZ4v1vgxyiaAqiXyy6YgXRBF5Q1rbAmmASGN4x86uMD3rSQTWYx9dszBoyDcoyfIhTzlxFDeUsCVdR5J3BOmfnIPKX16RZKZ02LNlibWnM0Dstjqq4AZ0qlRgu27DEGU_nT6sJurc" alt="Architecture diagram" width="856" height="698"&gt;&lt;/a&gt;&lt;br&gt;
Once installed, search for manga, add to your library, and enable &lt;strong&gt;auto-download&lt;/strong&gt; for new chapters. The server becomes largely autonomous — new chapters are downloaded in the background, and your library updates on a schedule.&lt;/p&gt;
&lt;h3&gt;
  
  
  Jellyfin: Movies, TV, and Music
&lt;/h3&gt;

&lt;p&gt;Jellyfin is my unified media server. From the single docker-compose above, I get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Movies &amp;amp; TV&lt;/strong&gt; — organized by metadata scrapers, streamed to any device&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Music&lt;/strong&gt; — with album art, artist info, and gapless playback&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live TV&lt;/strong&gt; (optional) — if you plug in a tuner&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The library structure maps directly to &lt;code&gt;/srv/media/&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Library Type&lt;/th&gt;
&lt;th&gt;Path in Container&lt;/th&gt;
&lt;th&gt;Path on Disk&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Movies&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/media/movies&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/srv/media/movies&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TV Shows&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/media/tv&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/srv/media/tv&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Music&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/media/music&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/srv/media/music&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Inside Jellyfin WebUI, add each library:&lt;/span&gt;
&lt;span class="c"&gt;# Dashboard → Libraries → Add Media Library&lt;/span&gt;
&lt;span class="c"&gt;# Type: Movies → Folder: /media/movies&lt;/span&gt;
&lt;span class="c"&gt;# Type: TV Shows → Folder: /media/tv&lt;/span&gt;
&lt;span class="c"&gt;# Type: Music → Folder: /media/music&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Configure the "Prefer ARTISTS tag if available" setting under the Music library for correct artist metadata.&lt;/p&gt;
&lt;h2&gt;
  
  
  How I Consume Everything Across Devices
&lt;/h2&gt;

&lt;p&gt;The best part of this setup is that content follows me everywhere. Here is exactly how each device connects:&lt;/p&gt;
&lt;h3&gt;
  
  
  iPhone
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNptUF1Lw0AQ_CvLPrcqRasGKRQ_0OKDuJE8JD5ccxt7cB_heqeU0v_u1bS2QffpZmeWmZs11k4yZvjhRbuA59fKQpplnHeLCtXLwlmGW63YBiAOsa2wU21nNipnrPWqURambXsz96eTxjuzBUDBeX4_iGlUkmiEV1Dw_O3pRzyVkiUEB4_OMFDtme3uhK3sHn9CEftP9r0cD4cc2dXZ9fjYtigpfomVMwqy84vx5RGXU5kLpZe10Py_7WwEw-EkGXSQOkhFj82px-5hTjA8OTreYypwgIa9EUqm8tcVhgUbrjBLn7Mcgxe6wk0SxVaKwPdSpSoxa4Re8gBFDI5Wtv5ddKo7JVJBBrPgI2--Af7AjDk" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNptUF1Lw0AQ_CvLPrcqRasGKRQ_0OKDuJE8JD5ccxt7cB_heqeU0v_u1bS2QffpZmeWmZs11k4yZvjhRbuA59fKQpplnHeLCtXLwlmGW63YBiAOsa2wU21nNipnrPWqURambXsz96eTxjuzBUDBeX4_iGlUkmiEV1Dw_O3pRzyVkiUEB4_OMFDtme3uhK3sHn9CEftP9r0cD4cc2dXZ9fjYtigpfomVMwqy84vx5RGXU5kLpZe10Py_7WwEw-EkGXSQOkhFj82px-5hTjA8OTreYypwgIa9EUqm8tcVhgUbrjBLn7Mcgxe6wk0SxVaKwPdSpSoxa4Re8gBFDI5Wtv5ddKo7JVJBBrPgI2--Af7AjDk" alt="Architecture diagram" width="742" height="300"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Jellyfin&lt;/strong&gt;: Install the Jellyfin Mobile app from the App Store, sign in with &lt;code&gt;http://ohara:8096&lt;/code&gt; (the Tailscale MagicDNS hostname resolves over the mesh)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manga (Suwayomi)&lt;/strong&gt;: Open Safari → &lt;code&gt;http://ohara:4567&lt;/code&gt; → log in → tap the Share button → &lt;strong&gt;Add to Home Screen&lt;/strong&gt;. It behaves like a native app: full-screen, dedicated icon, persistent session&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailscale&lt;/strong&gt;: Always-on in the background. Without it, none of the above works outside my home WiFi&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Android TV
&lt;/h3&gt;

&lt;p&gt;My TV runs Android TV, which does not come with Tailscale or Jellyfin pre-installed. You can sideload both via ADB:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Enable Developer Options on TV&lt;/span&gt;
&lt;span class="c"&gt;# Settings → Device Preferences → About → Build → tap 7 times&lt;/span&gt;

&lt;span class="c"&gt;# 2. Enable USB Debugging&lt;/span&gt;
&lt;span class="c"&gt;# Settings → Developer Options → USB Debugging → ON&lt;/span&gt;

&lt;span class="c"&gt;# 3. Find TV IP from network settings&lt;/span&gt;
&lt;span class="c"&gt;# Example: 192.168.1.100&lt;/span&gt;

&lt;span class="c"&gt;# 4. On your Mac/Laptop, connect and install&lt;/span&gt;
adb connect 192.168.1.100
adb &lt;span class="nb"&gt;install &lt;/span&gt;jellyfin-androidtv.apk
adb &lt;span class="nb"&gt;install &lt;/span&gt;tailscale.apk

&lt;span class="c"&gt;# 5. On the TV, open Tailscale → log in&lt;/span&gt;
&lt;span class="c"&gt;# 6. Open Jellyfin → sign in with http://ohara:8096&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once set up, the TV works outside home too — take it to a different city, connect to WiFi, Tailscale reconnects automatically and Jellyfin works.&lt;/p&gt;

&lt;h3&gt;
  
  
  MacBook
&lt;/h3&gt;

&lt;p&gt;The MacBook is my management machine. I use it for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# SSH into the server from anywhere&lt;/span&gt;
ssh ohara

&lt;span class="c"&gt;# Rsync backups&lt;/span&gt;
rsync &lt;span class="nt"&gt;-avz&lt;/span&gt; &lt;span class="nt"&gt;--progress&lt;/span&gt; ohara:/srv/media/manga/ ~/ohara-backup/manga/

&lt;span class="c"&gt;# Edit compose files remotely&lt;/span&gt;
ssh ohara &lt;span class="s2"&gt;"vim /srv/appdata/docker-compose.yml"&lt;/span&gt;

&lt;span class="c"&gt;# Rebuild containers after config changes&lt;/span&gt;
ssh ohara &lt;span class="s2"&gt;"cd /srv/appdata &amp;amp;&amp;amp; docker compose up -d"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For media consumption, I use the Jellyfin WebUI in a browser. There are desktop clients (Jellyfin Media Player, Swiftfin) but the WebUI is good enough for occasional viewing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Windows Laptop
&lt;/h3&gt;

&lt;p&gt;The Windows laptop serves as a secondary backup target. Over Tailscale:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Pull backup from server&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;rsync&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-avz&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;prateek&lt;/span&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="nx"&gt;ohara:/srv/media/manga/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;D:\ohara-backup\manga\&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# Push files to server&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;rsync&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-avz&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;D:\documents\&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;prateek&lt;/span&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="nx"&gt;ohara:/srv/media/documents\&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No SMB shares, no mapped drives, no exposed ports — just SSH and rsync over the encrypted mesh.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Complete Architecture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNqFU1Fv2jAQ_iuWXwdlD1u3oakStEMFkQ2RlDwkfTjig1gkvshxQKjqf68TExropN5L7M_f3X1nf3nhCQnkQ77VUKQsGMeK2SirtQNi_kg5sr9oDqR3MXfHdSyjJVUG9TPr9--YHwaRf5AmSZ_fKRZ0h9G_FDQwH_Ue9e-1Htw9rStlKvaFPVCys0VcEirhFh9UOBq7J2VAKtRlV4qQGhMjSZ311zGbRDPMsuNGqqblgrRhP7_-ur1QGPnVAY6Uy3fOt--3Pz4V5BvSsMWuDC8alHo_yFFIaKp5tJdY9liw6jGvKmViP6C2MOgoGLkkKAoBxqXZITdyW9rL8dBADQ8-lROAzMoEMrQ5Zfq_9_LGkQfJmGjXNjGaMttkDMmuKjqSpotILlJS6Iaox6m11MrZfSZRmQ45WEUjJTRJYZdNwlzupdqyJVHOFhkcz69bRziPQqkEHUo2h8JQ0aT4mJASoI-XYq7G9RszOSc45OSKGp5NPkB-6KDZpNl6pyphd3c6G12cjbpdvTHr39QP7j_aW9DlUSUxZzd1fUeYLhyhNRsbFcWJ0YpqKa3XWIjrp2lb5iQzWF3V2ZC24FWpcO5YF0J4j-eoc5DC_skvMTcp5taaQ8tTWBkNWcxfLamqTYZ_hLTm5cMNZCX2OFSGfFvsDDjWgwRrrpwPja7w9Q0G4TwE" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNqFU1Fv2jAQ_iuWXwdlD1u3oakStEMFkQ2RlDwkfTjig1gkvshxQKjqf68TExropN5L7M_f3X1nf3nhCQnkQ77VUKQsGMeK2SirtQNi_kg5sr9oDqR3MXfHdSyjJVUG9TPr9--YHwaRf5AmSZ_fKRZ0h9G_FDQwH_Ue9e-1Htw9rStlKvaFPVCys0VcEirhFh9UOBq7J2VAKtRlV4qQGhMjSZ311zGbRDPMsuNGqqblgrRhP7_-ur1QGPnVAY6Uy3fOt--3Pz4V5BvSsMWuDC8alHo_yFFIaKp5tJdY9liw6jGvKmViP6C2MOgoGLkkKAoBxqXZITdyW9rL8dBADQ8-lROAzMoEMrQ5Zfq_9_LGkQfJmGjXNjGaMttkDMmuKjqSpotILlJS6Iaox6m11MrZfSZRmQ45WEUjJTRJYZdNwlzupdqyJVHOFhkcz69bRziPQqkEHUo2h8JQ0aT4mJASoI-XYq7G9RszOSc45OSKGp5NPkB-6KDZpNl6pyphd3c6G12cjbpdvTHr39QP7j_aW9DlUSUxZzd1fUeYLhyhNRsbFcWJ0YpqKa3XWIjrp2lb5iQzWF3V2ZC24FWpcO5YF0J4j-eoc5DC_skvMTcp5taaQ8tTWBkNWcxfLamqTYZ_hLTm5cMNZCX2OFSGfFvsDDjWgwRrrpwPja7w9Q0G4TwE" alt="Architecture diagram" width="1325" height="919"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Backup Strategy
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# From Mac — pull a full media backup&lt;/span&gt;
rsync &lt;span class="nt"&gt;-avz&lt;/span&gt; &lt;span class="nt"&gt;--progress&lt;/span&gt; ohara:/srv/media/ ~/ohara-backup/

&lt;span class="c"&gt;# From Windows — mirror to external drive&lt;/span&gt;
rsync &lt;span class="nt"&gt;-avz&lt;/span&gt; prateek@ohara:/srv/media/manga/ D:&lt;span class="se"&gt;\o&lt;/span&gt;hara-backup&lt;span class="se"&gt;\m&lt;/span&gt;anga&lt;span class="se"&gt;\&lt;/span&gt;

&lt;span class="c"&gt;# From server itself — backup to USB drive&lt;/span&gt;
rsync &lt;span class="nt"&gt;-avz&lt;/span&gt; /srv/appdata/ /mnt/usb-backup/appdata/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three golden rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Always use &lt;code&gt;--dry-run&lt;/code&gt; first&lt;/strong&gt; — rsync is silent when it works and destructive when it doesn't&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trailing slashes matter&lt;/strong&gt; — &lt;code&gt;ohara:/srv/media/&lt;/code&gt; copies contents, &lt;code&gt;ohara:/srv/media&lt;/code&gt; copies the folder itself&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No SMB needed&lt;/strong&gt; — SSH + rsync is cleaner, scriptable, and works over Tailscale from anywhere&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What Running This Feels Like Day to Day
&lt;/h2&gt;

&lt;p&gt;The server is invisible. It sits under my desk, silent, doing its job. Here is what a typical day looks like:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Morning&lt;/strong&gt;: New manga chapters were downloaded overnight (Suwayomi auto-updates at 3 AM)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Afternoon&lt;/strong&gt;: I start a movie on the TV via Jellyfin, pause it, continue on my phone in another room&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evening&lt;/strong&gt;: I discover a new manga on my phone, add it to my library via Suwayomi WebUI&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Night&lt;/strong&gt;: An automated rsync cron job backs up appdata to an external drive&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The only time I SSH into the server is when I add a new service or update a container. Everything else runs itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "Ohara"?
&lt;/h2&gt;

&lt;p&gt;In &lt;em&gt;One Piece&lt;/em&gt;, Ohara was the island of scholars — a place where knowledge was pursued, preserved, and archived. The World Government destroyed it for holding too much knowledge, but the legacy survived through the survivors who carried books in their memory.&lt;/p&gt;

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

&lt;p&gt;A home server is a much smaller act of preservation. But it is the same impulse: keep your own things on your own hardware, under your own control. Your media library, your manga collection, your files — they should outlive any streaming service's licensing decisions or any cloud provider's terms of service changes.&lt;/p&gt;

&lt;p&gt;You do not need a data hoarding disorder to want that. You just need an old PC, a weekend, and the willingness to &lt;code&gt;ssh&lt;/code&gt; into a box that lives under your desk.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Will Have at the End
&lt;/h2&gt;

&lt;p&gt;✅ A 24/7 headless server that needs no monitor, keyboard, or mouse&lt;br&gt;&lt;br&gt;
✅ Remote access from anywhere via Tailscale — no ports open to the internet&lt;br&gt;&lt;br&gt;
✅ A full media server (Jellyfin) for movies, TV, and music&lt;br&gt;&lt;br&gt;
✅ A self-hosted manga server (Suwayomi) with auto-downloads and cross-device sync&lt;br&gt;&lt;br&gt;
✅ Clients working on iPhone, Android TV, Mac, and Windows&lt;br&gt;&lt;br&gt;
✅ An automated backup pipeline over SSH&lt;br&gt;&lt;br&gt;
✅ Infrastructure-as-code with Docker Compose — rebuild the entire server from a single YAML file&lt;/p&gt;

&lt;p&gt;The barrier to entry for homelabbing has never been lower. Ubuntu Server is free. Docker Compose turns deployment into a single YAML file. Tailscale makes networking trivially secure. All you need is the hardware you already have lying around, gathering dust.&lt;/p&gt;

&lt;p&gt;Go build your Ohara.&lt;/p&gt;




&lt;p&gt;If I have used any images that need attribution, please &lt;a href="mailto:prateek11rai@protonmail.com"&gt;contact&lt;/a&gt; me.&lt;/p&gt;

</description>
      <category>selfhosting</category>
      <category>homelab</category>
      <category>jellyfin</category>
      <category>tailscale</category>
    </item>
  </channel>
</rss>
