<?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: Developer Service</title>
    <description>The latest articles on DEV Community by Developer Service (@devasservice).</description>
    <link>https://dev.to/devasservice</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%2F741018%2F3ae8dc9d-0bc6-493c-a34c-f4b4d713a393.png</url>
      <title>DEV Community: Developer Service</title>
      <link>https://dev.to/devasservice</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/devasservice"/>
    <language>en</language>
    <item>
      <title>I Installed Free-Threaded Python - The GIL Came Back Anyway</title>
      <dc:creator>Developer Service</dc:creator>
      <pubDate>Mon, 21 Sep 2026 07:20:32 +0000</pubDate>
      <link>https://dev.to/devasservice/i-installed-free-threaded-python-the-gil-came-back-anyway-1n7</link>
      <guid>https://dev.to/devasservice/i-installed-free-threaded-python-the-gil-came-back-anyway-1n7</guid>
      <description>&lt;p&gt;Python 3.14 made the free-threaded build officially supported. Nice change log line.&lt;/p&gt;

&lt;p&gt;I installed &lt;code&gt;3.14t&lt;/code&gt; the way I install anything I'm excited about, way too fast, and with one bad assumption: that the build was enough.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;python -VV&lt;/code&gt; said free-threading build. I spun up threads on a CPU-bound job and waited for the cores to light up. Wall time barely moved. Four threads, one core's worth of progress.&lt;/p&gt;

&lt;p&gt;Nothing crashed. Nothing warned. &lt;/p&gt;

&lt;p&gt;My threads didn't fail, they went back to taking turns. A C extension I'd imported wasn't ready, so the runtime quietly put the GIL back on. &lt;/p&gt;

&lt;p&gt;I only knew because I finally asked &lt;code&gt;sys._is_gil_enabled()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That afternoon is why I wrote this down.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why I cared
&lt;/h2&gt;

&lt;p&gt;For twenty years I lived with the same rule everyone else did. The global interpreter lock means only one thread runs Python byte code at a time. Threads were fine when I was waiting on sockets. Useless when I wanted CPU. Need cores? Pay for processes.&lt;/p&gt;

&lt;p&gt;Python 3.13 shipped a free-threaded build as an experiment. Python 3.14 made it officially supported, although still optional, still not the default. &lt;/p&gt;

&lt;p&gt;Most people who install 3.14 still get the GIL. I wanted a narrower answer: if I install the other build, what actually gets faster, and what quietly puts the lock back?&lt;/p&gt;

&lt;p&gt;So I set up a small lab experiment with uv, a stdlib-only benchmark, and one unmarked C extension.&lt;/p&gt;




&lt;h2&gt;
  
  
  Setting up the lab
&lt;/h2&gt;

&lt;p&gt;Free-threading isn't a flag on normal 3.14. It's a separate interpreter: &lt;code&gt;python3.14t&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;With uv I ask for &lt;code&gt;3.14t&lt;/code&gt;, pin it in the project, and keep &lt;code&gt;requires-python = "&amp;gt;=3.14"&lt;/code&gt; in &lt;code&gt;pyproject.toml&lt;/code&gt;, the &lt;code&gt;t&lt;/code&gt; is a build variant, not a version number, and uv will refuse &lt;code&gt;&amp;gt;=3.14t&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="nv"&gt;$ &lt;/span&gt;uv python &lt;span class="nb"&gt;install &lt;/span&gt;3.14 3.14t
Installed 2 versions &lt;span class="k"&gt;in &lt;/span&gt;2.32s
 + cpython-3.14.5+freethreaded-linux-x86_64-gnu &lt;span class="o"&gt;(&lt;/span&gt;python3.14t&lt;span class="o"&gt;)&lt;/span&gt;
 + cpython-3.14.5-linux-x86_64-gnu &lt;span class="o"&gt;(&lt;/span&gt;python3.14&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;$ &lt;/span&gt;uv python pin 3.14t
Pinned &lt;span class="sb"&gt;`&lt;/span&gt;.python-version&lt;span class="sb"&gt;`&lt;/span&gt; to &lt;span class="sb"&gt;`&lt;/span&gt;3.14+freethreaded&lt;span class="sb"&gt;`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When I want the normal GIL build back for a comparison, I ask for it by name. A pinned &lt;code&gt;3.14t&lt;/code&gt; plus an existing &lt;code&gt;.venv&lt;/code&gt; will swallow a vague &lt;code&gt;--python 3.14&lt;/code&gt;, and I'll think I'm comparing when I'm not.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv run &lt;span class="nt"&gt;--no-project&lt;/span&gt; &lt;span class="nt"&gt;--isolated&lt;/span&gt; &lt;span class="nt"&gt;--python&lt;/span&gt; 3.14+gil python benchmark.py
uv run &lt;span class="nt"&gt;--no-project&lt;/span&gt; &lt;span class="nt"&gt;--isolated&lt;/span&gt; &lt;span class="nt"&gt;--python&lt;/span&gt; 3.14t python benchmark.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






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

&lt;p&gt;I kept the first benchmark pure Python on purpose: format a row, split it, fold integers. &lt;/p&gt;

&lt;p&gt;Same total work, one thread then four. &lt;/p&gt;

&lt;p&gt;Libraries that drop the lock from C would fake a win on the default build, and I'd be measuring the wrong thing.&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;from&lt;/span&gt; &lt;span class="n"&gt;concurrent.futures&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ThreadPoolExecutor&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;crunch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&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="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&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;n&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&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;i&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;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;i&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;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;97&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="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="n"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;^=&lt;/span&gt; &lt;span class="n"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;=&lt;/span&gt; &lt;span class="mh"&gt;0xFFFFFFFF&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;acc&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;wall_time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&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;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;divmod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;workers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sizes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;rem&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;0&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;i&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;workers&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="n"&gt;started&lt;/span&gt; &lt;span class="o"&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;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;ThreadPoolExecutor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_workers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;workers&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;pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;crunch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sizes&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;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;started&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full script in &lt;a href="https://github.com/nunombispo/python-threads-gil-article" rel="noopener noreferrer"&gt;the lab repo&lt;/a&gt; prints interpreter metadata, repeats the run, and keeps the best wall time.&lt;/p&gt;

&lt;p&gt;Let's now run the benchmark:&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;uv run &lt;span class="nt"&gt;--no-project&lt;/span&gt; &lt;span class="nt"&gt;--isolated&lt;/span&gt; &lt;span class="nt"&gt;--python&lt;/span&gt; 3.14+gil python benchmark.py
Python          3.14.5 &lt;span class="o"&gt;(&lt;/span&gt;main, May 10 2026, 19:28:16&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;Clang 22.1.3 &lt;span class="o"&gt;]&lt;/span&gt;
Py_GIL_DISABLED 0
GIL enabled     True
CPU count       4
total rows      2,000,000  &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;split &lt;/span&gt;across threads&lt;span class="o"&gt;)&lt;/span&gt;

 threads      wall s   speedup
       1       1.210     1.00x
       4       2.133     0.57x

&lt;span class="nv"&gt;$ &lt;/span&gt;uv run &lt;span class="nt"&gt;--no-project&lt;/span&gt; &lt;span class="nt"&gt;--isolated&lt;/span&gt; &lt;span class="nt"&gt;--python&lt;/span&gt; 3.14t python benchmark.py
Python          3.14.5 free-threading build &lt;span class="o"&gt;(&lt;/span&gt;main, May 10 2026, 19:27:52&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;Clang 22.1.3 &lt;span class="o"&gt;]&lt;/span&gt;
Py_GIL_DISABLED 1
GIL enabled     False
CPU count       4
total rows      2,000,000  &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;split &lt;/span&gt;across threads&lt;span class="o"&gt;)&lt;/span&gt;

 threads      wall s   speedup
       1       1.174     1.00x
       4       0.320     3.67x
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Default 3.14: four threads were slower than one (0.57×). Contention for the lock, not a free lunch. 3.14t: about 3.7× on four cores.&lt;/p&gt;

&lt;p&gt;That speedup is the clean-lab ceiling, not a promise. This job shares nothing: no shared dict, no dataframe, no lock. &lt;/p&gt;

&lt;p&gt;Contention and native code that still serializes will pull real work toward 2×. If I see 1.1×, I'm paying a single-thread tax (often a few percent, sometimes closer to 10%) for nothing.&lt;/p&gt;

&lt;p&gt;But the reason I still don't trust a fresh &lt;code&gt;3.14t&lt;/code&gt; pin isn't bad scaling. It's quieter than that.&lt;/p&gt;




&lt;h2&gt;
  
  
  The thing that ate my afternoon
&lt;/h2&gt;

&lt;p&gt;The build can say free-threaded and still be running with the GIL on. &lt;code&gt;python -VV&lt;/code&gt; and a compile-time flag only tell me which interpreter I installed. &lt;code&gt;sys._is_gil_enabled()&lt;/code&gt; tells me what's happening right now.&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;sys&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sysconfig&lt;/span&gt;

&lt;span class="n"&gt;sysconfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_config_var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Py_GIL_DISABLED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# 1 on a free-threaded build
&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_is_gil_enabled&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                        &lt;span class="c1"&gt;# False — until something flips it
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I need both. That's how I burned the afternoon.&lt;/p&gt;

&lt;p&gt;Import a C extension that hasn't said it can run without the GIL, and the interpreter turns the lock back on for the rest of the process. It prints a warning. It doesn't raise. &lt;/p&gt;

&lt;p&gt;The benchmark keeps running. The threads keep looking busy. Throughput goes back to taking turns.&lt;/p&gt;

&lt;p&gt;The detector I use is a loop:&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;importlib&lt;/span&gt;&lt;span class="p"&gt;,&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;warnings&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;before&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_is_gil_enabled&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;  &lt;span class="c1"&gt;# False on a fresh 3.14t
&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;numpy&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;pandas&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;gil_trap&lt;/span&gt;&lt;span class="sh"&gt;"&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;warnings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;catch_warnings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&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;caught&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;warnings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;simplefilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;importlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;import_module&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="nf"&gt;print&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="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_is_gil_enabled&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;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;caught&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gil&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&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;print&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;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I start free-threaded. Import dependencies one by one. Watch for the flip.&lt;/p&gt;

&lt;p&gt;The lab at &lt;a href="https://github.com/nunombispo/python-threads-gil-article" rel="noopener noreferrer"&gt;python-threads-gil-article&lt;/a&gt; ships &lt;code&gt;trap/&lt;/code&gt;: a tiny C extension that does no work and never declares itself safe. That's enough.&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;uv run &lt;span class="nt"&gt;--no-project&lt;/span&gt; &lt;span class="nt"&gt;--isolated&lt;/span&gt; &lt;span class="nt"&gt;--python&lt;/span&gt; 3.14t &lt;span class="nt"&gt;--with&lt;/span&gt; ./trap python gil_detector.py gil_trap

      Built gil-trap @ file:///.../python-threads-gil-article/trap
Installed 1 package &lt;span class="k"&gt;in &lt;/span&gt;0.79ms
Python          3.14.5 free-threading build &lt;span class="o"&gt;(&lt;/span&gt;main, May 10 2026, 19:27:52&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;Clang 22.1.3 &lt;span class="o"&gt;]&lt;/span&gt;
Py_GIL_DISABLED 1

before imports               &lt;span class="nv"&gt;gil_enabled&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;False

gil_trap                     &lt;span class="nv"&gt;gil_enabled&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;True  &amp;lt;&lt;span class="nt"&gt;--&lt;/span&gt; GIL re-enabled
                             warning: The global interpreter lock &lt;span class="o"&gt;(&lt;/span&gt;GIL&lt;span class="o"&gt;)&lt;/span&gt; has been enabled to load module &lt;span class="s1"&gt;'gil_trap'&lt;/span&gt;, which has not declared that it can run safely without the GIL. To override this behavior and keep the GIL disabled &lt;span class="o"&gt;(&lt;/span&gt;at your own risk&lt;span class="o"&gt;)&lt;/span&gt;, run with &lt;span class="nv"&gt;PYTHON_GIL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 or &lt;span class="nt"&gt;-Xgil&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.

after all imports            &lt;span class="nv"&gt;gil_enabled&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;True
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PYTHON_GIL=0&lt;/code&gt; after that warning is not a fix. It's a dare. The module told me it isn't safe. I should believe it.&lt;/p&gt;

&lt;p&gt;I re-ran the benchmark after importing &lt;code&gt;gil_trap&lt;/code&gt;. The 3.7× was gone: back to taking turns, same story as default 3.14.&lt;/p&gt;

&lt;p&gt;What about the stack I already run? On this machine, current NumPy, pandas, SciPy, Pydantic, and FastAPI imported clean: lock still off after each.&lt;/p&gt;

&lt;p&gt;Having a free-threaded wheel isn't the same as scaling under threads either. Some paths still take their own locks. Async and I/O don't care much: the GIL was never what made &lt;code&gt;await&lt;/code&gt; work. &lt;/p&gt;

&lt;p&gt;The thing that worried me was the private wheel, the pinned 18-month-old native module, the "works on 3.14" package that only shipped the normal build. I look for the &lt;code&gt;t&lt;/code&gt; in the package tag and keep an eye on &lt;a href="https://py-free-threading.github.io/tracking/" rel="noopener noreferrer"&gt;py-free-threading&lt;/a&gt; and &lt;a href="https://hugovk.github.io/free-threaded-wheels/" rel="noopener noreferrer"&gt;free-threaded wheels&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where I landed
&lt;/h2&gt;

&lt;p&gt;I stay on default 3.14 when the work is mostly waiting: sockets, databases, HTTP. Async already overlaps that. Free-threading won't make FastAPI handlers magically use four cores while they talk to Postgres.&lt;/p&gt;

&lt;p&gt;I stay off it for single-threaded CLIs too. A little slower, no parallelism, nothing to show for the pin. And if the hot path is a native library that still serializes, or a wheel that re-enables the GIL, I'd just be paying a tax to keep the lock.&lt;/p&gt;

&lt;p&gt;I still reach for processes when isolation matters more than shared memory: crash domains, messy native code.&lt;/p&gt;

&lt;p&gt;I reach for &lt;code&gt;3.14t&lt;/code&gt; when the work is CPU-bound Python that already shares memory awkwardly across processes: parallel transforms, image or PDF pipelines, batch work over local files. &lt;/p&gt;

&lt;p&gt;And only when I control the dependency set: a greenfield worker or a small internal tool, not the forty-wheel monolith I inherited. Before I trust threads on that build, I run the detector on a cold start, and in CI, after every import that matters.&lt;/p&gt;

&lt;p&gt;Free-threading in 3.14 is real. On my lab box, four threads finished that CPU job in about a third of the time. It's also a different interpreter and a dependency veto. One unmarked native module and I'm back to one core, with a warning that looks like noise.&lt;/p&gt;

&lt;p&gt;Try the &lt;a href="https://github.com/nunombispo/python-threads-gil-article" rel="noopener noreferrer"&gt;lab&lt;/a&gt; on your hottest CPU path. Then tell me what broke.&lt;/p&gt;




&lt;p&gt;Follow me on Twitter: &lt;a href="https://twitter.com/DevAsService" rel="noopener noreferrer"&gt;https://twitter.com/DevAsService&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on Instagram: &lt;a href="https://www.instagram.com/devasservice/" rel="noopener noreferrer"&gt;https://www.instagram.com/devasservice/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on TikTok: &lt;a href="https://www.tiktok.com/@devasservice" rel="noopener noreferrer"&gt;https://www.tiktok.com/@devasservice&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on YouTube: &lt;a href="https://www.youtube.com/@DevAsService" rel="noopener noreferrer"&gt;https://www.youtube.com/@DevAsService&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>freethreaded</category>
      <category>gil</category>
    </item>
    <item>
      <title>Building an MCP Server for Dokku with FastMCP</title>
      <dc:creator>Developer Service</dc:creator>
      <pubDate>Fri, 11 Sep 2026 11:11:37 +0000</pubDate>
      <link>https://dev.to/devasservice/building-an-mcp-server-for-dokku-with-fastmcp-b5h</link>
      <guid>https://dev.to/devasservice/building-an-mcp-server-for-dokku-with-fastmcp-b5h</guid>
      <description>&lt;p&gt;You are already in Cursor, mid-debug, when you need to know whether an app is up, what its domains are, or why the last deploy blew up.&lt;/p&gt;

&lt;p&gt;The usual path is a context switch: open a terminal, SSH into the Dokku host, run the right plugin commands, paste the output back into chat. By then the thread of the problem has gone cold.&lt;/p&gt;

&lt;p&gt;What if you could stay in the conversation instead? Type “is &lt;code&gt;api&lt;/code&gt; running, and show me the last fifty log lines” and let the agent hit Dokku for you: same host, same SSH credentials you already trust, without shuttling shell output by hand.&lt;/p&gt;

&lt;p&gt;That is what an MCP server is for: tools the model can call while you keep working in the editor.&lt;/p&gt;

&lt;p&gt;This post walks through &lt;a href="https://github.com/nunombispo/dokku-mcp-server" rel="noopener noreferrer"&gt;dokku-mcp&lt;/a&gt;, a FastMCP server that talks to Dokku over SSH, returns structured results, and ships with guardrails so “convenient” does not become “unrestricted shell access.”&lt;/p&gt;




&lt;h2&gt;
  
  
  What MCP actually is
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://modelcontextprotocol.io/" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt; is a small contract between an AI client and a side process that owns capabilities the model should not invent on its own: database queries, ticket APIs, or your Dokku host.&lt;/p&gt;

&lt;p&gt;In the local setup Cursor and Claude Desktop use most often, the client starts your server as a subprocess and talks to it over &lt;strong&gt;stdio&lt;/strong&gt; (JSON-RPC on stdin/stdout). At connect time the server advertises a catalog of &lt;em&gt;tools&lt;/em&gt;: name, description, and a JSON schema for arguments. &lt;/p&gt;

&lt;p&gt;When you ask something in chat, the model can choose a tool, the client invokes it on the server, and the result is fed back into the conversation as structured data, not as something you pasted from a terminal.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://gofastmcp.com/" rel="noopener noreferrer"&gt;FastMCP&lt;/a&gt; turns Python async functions into that catalog with almost no configuration: decorate or register a function, document side effects in the docstring (clients surface those to the model), and call &lt;code&gt;mcp.run()&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The protocol handles discovery and invocation; your job is deciding which operations exist and what they are allowed to do.&lt;/p&gt;




&lt;h2&gt;
  
  
  Setup: skeleton and SSH
&lt;/h2&gt;

&lt;p&gt;The rest of this post follows the code in &lt;a href="https://github.com/nunombispo/dokku-mcp-server" rel="noopener noreferrer"&gt;dokku-mcp&lt;/a&gt; as it ships today. The package lives under &lt;code&gt;src/dokku_mcp/&lt;/code&gt; with a thin layout:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;server.py&lt;/code&gt; — FastMCP instance and tool registration
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ssh.py&lt;/code&gt; — shared &lt;code&gt;asyncssh&lt;/code&gt; session
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;config.py&lt;/code&gt; — env settings and allow/deny lists
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;parsers.py&lt;/code&gt; — CLI stdout → structured data
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tools/&lt;/code&gt; — one module per Dokku concern
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Dependencies are the obvious ones: &lt;code&gt;fastmcp&lt;/code&gt;, &lt;code&gt;asyncssh&lt;/code&gt;, &lt;code&gt;pydantic-settings&lt;/code&gt;, &lt;code&gt;python-dotenv&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One Dokku-specific gotcha matters more than the framework choice. Dokku’s SSH user uses a forced command. You do &lt;strong&gt;not&lt;/strong&gt; run &lt;code&gt;dokku apps:list&lt;/code&gt; in a remote shell. You SSH as &lt;code&gt;dokku&lt;/code&gt; and send the plugin invocation as the remote command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh dokku@your-host apps:list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The connection layer mirrors that. One shared session per process, a lock so concurrent tool calls don’t interleave, and reconnect if the link drops:&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;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Run a Dokku plugin command over SSH.

    Dokku&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s forced SSH command expects the plugin invocation as the remote
    command (e.g. ``apps:list``), not ``dokku apps:list``.
    &lt;/span&gt;&lt;span class="sh"&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;args&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;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DokkuSSH.run requires at least one argument&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;command&lt;/span&gt; &lt;span class="o"&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;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_shell_quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&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;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_lock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_run_unlocked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Config comes from the environment (&lt;code&gt;DOKKU_HOST&lt;/code&gt;, &lt;code&gt;DOKKU_SSH_USER&lt;/code&gt;, &lt;code&gt;DOKKU_SSH_KEY_PATH&lt;/code&gt;, …). Defaults favor the Dokku convention: user &lt;code&gt;dokku&lt;/code&gt;, port &lt;code&gt;22&lt;/code&gt;, mode &lt;code&gt;read-only&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tool #1: &lt;code&gt;list_apps&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The first useful tool is also the simplest: list apps, prefer JSON, fall back to quiet text, then filter through the allowlist.&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;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;list_apps&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;List Dokku apps on the host.

    Side effects: none (read-only).
    Respects DOKKU_APP_ALLOWLIST / DOKKU_APP_DENYLIST — denied apps are filtered out.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;settings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_settings&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;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;run_dokku&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;apps:list&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;--format&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;json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;DokkuSSHError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;run_dokku&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--quiet&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;apps:list&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;apps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_apps_list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&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;app&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;apps&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_app_allowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register it on the FastMCP instance and you’re live:&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;from&lt;/span&gt; &lt;span class="n"&gt;fastmcp&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastMCP&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dokku_mcp.tools&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;apps&lt;/span&gt;

&lt;span class="n"&gt;mcp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastMCP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dokku&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;list_apps&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;main&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="n"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is enough for the server process. Wire Cursor (or Claude Desktop) as in the the Example section below, the client config is the same whether you expose one tool or the full set.&lt;/p&gt;




&lt;h2&gt;
  
  
  Expand the tool set (and face the parsing problem)
&lt;/h2&gt;

&lt;p&gt;Read-only coverage grows the same way: one Dokku command, one tool, one parser.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Dokku command&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;code&gt;app_report&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ps:report &amp;lt;app&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Process / running state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app_config&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;config:export &amp;lt;app&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Secrets masked by default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app_logs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;logs &amp;lt;app&amp;gt; -n &amp;lt;n&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Default 50 lines, hard max 500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app_domains&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;domains:report &amp;lt;app&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;VHOST report&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app_url&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;derived&lt;/td&gt;
&lt;td&gt;HTTPS URLs from domains&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Dokku isn’t a JSON API. Newer plugins often accept &lt;code&gt;--format json&lt;/code&gt;; older output is banner text and &lt;code&gt;Key: value&lt;/code&gt; lines. The server prefers JSON when it works, then falls back to text parsers, never a generic “run whatever string the model invents” tool.&lt;/p&gt;

&lt;p&gt;That last point is deliberate. An open-ended &lt;code&gt;dokku_exec&lt;/code&gt; would be convenient for demos and disastrous in production. Structured tools keep the surface small, the docstrings honest about side effects, and the parsers testable against fixture files of real CLI output.&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;parse_apps_list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&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;list&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Parse ``apps:list`` JSON or quiet text output.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw&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="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&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;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&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;data&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;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&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;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&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;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;data&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;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;apps:list JSON was not a list&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;apps&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="nb"&gt;str&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;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splitlines&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;line&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="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&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="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;line&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="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my apps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;apps&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="n"&gt;line&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;apps&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same pattern for reports, &lt;code&gt;config:export&lt;/code&gt;, and logs: small functions, fixture-backed unit tests, no scraping surprises at runtime.&lt;/p&gt;




&lt;h2&gt;
  
  
  Guardrails: don’t blindly trust AI with infra
&lt;/h2&gt;

&lt;p&gt;Giving a model SSH into your PaaS is only interesting if you assume it will eventually ask for something you don’t want. Guardrails are that protection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mode.&lt;/strong&gt; &lt;code&gt;DOKKU_MCP_MODE&lt;/code&gt; defaults to &lt;code&gt;read-only&lt;/code&gt;. Mutating tools call &lt;code&gt;require_write_mode()&lt;/code&gt; and fail closed unless you set &lt;code&gt;read-write&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Allowlist / denylist.&lt;/strong&gt; Empty allowlist means all apps; a non-empty list is an explicit permit set. Denylist always wins. Every app-scoped tool checks before SSH.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Secret masking.&lt;/strong&gt; &lt;code&gt;app_config&lt;/code&gt; returns env vars with &lt;code&gt;DATABASE_URL&lt;/code&gt;, &lt;code&gt;*_TOKEN&lt;/code&gt;, &lt;code&gt;*_PASSWORD&lt;/code&gt;, and friends replaced by &lt;code&gt;***&lt;/code&gt; unless the caller passes &lt;code&gt;reveal_secrets=True&lt;/code&gt;. The model can reason about which keys exist without leaking credentials into the chat transcript by default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Confirm for mutations.&lt;/strong&gt; Even in &lt;code&gt;read-write&lt;/code&gt;, restart and scale refuse to run unless &lt;code&gt;confirm=True&lt;/code&gt; is passed explicitly, never inferred from chatty assent:&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;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;restart_app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&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;confirm&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="nb"&gt;str&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Restart all processes for a Dokku app.

    Side effects: restarts the application (downtime possible).
    Requires DOKKU_MCP_MODE=read-write and confirm=True.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;settings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_settings&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;require_write_mode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;require_app_allowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&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;confirm&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;PermissionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;restart_app requires confirm=True to proceed — pass confirm=True explicitly&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;run_dokku&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ps:restart&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&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;restarted&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;output&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;output&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;set_config&lt;/code&gt; follows the same pattern and returns the &lt;em&gt;keys&lt;/em&gt; that were set, not the values. &lt;code&gt;create_app&lt;/code&gt; validates names (&lt;code&gt;^[a-z0-9-]+$&lt;/code&gt; with alphanumeric edges) and still requires write mode.&lt;/p&gt;

&lt;p&gt;What I deliberately left out: &lt;code&gt;destroy_app&lt;/code&gt;. Destroying an app is irreversible enough that “the model said yes” is not a controlled safety.&lt;/p&gt;

&lt;p&gt;Tool docstrings spell out side effects because MCP clients surface them to the model. That is part of the safety features, not just decoration.&lt;/p&gt;




&lt;h2&gt;
  
  
  Example: from chat to Dokku
&lt;/h2&gt;

&lt;p&gt;Assume the package is installed (or run from the repo with &lt;code&gt;uv sync&lt;/code&gt;). Before wiring Cursor, confirm SSH works the Dokku way, the same forced-command path the server will use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh dokku@your-host apps:list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that fails, fix the key, host, or Dokku user first; the MCP layer will not rescue a broken SSH setup.&lt;/p&gt;

&lt;p&gt;Then add the server to &lt;code&gt;~/.cursor/mcp.json&lt;/code&gt; (or the project &lt;code&gt;.cursor/mcp.json&lt;/code&gt;). From the repo with &lt;code&gt;uv&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;"mcpServers"&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;"dokku"&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;"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;"uv"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"run"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"--directory"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"~/dokku-mcp-server"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"dokku-mcp"&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;"env"&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;"DOKKU_HOST"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"xxx.xxx.xxx.xxx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"DOKKU_SSH_USER"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dokku"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"DOKKU_SSH_KEY_PATH"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"~/.ssh/id_rsa"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"DOKKU_MCP_MODE"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"read-only"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once published, &lt;code&gt;uvx dokku-mcp&lt;/code&gt; with the same &lt;code&gt;env&lt;/code&gt; block is enough.&lt;/p&gt;

&lt;p&gt;Reload MCP in Cursor, then stay in the agent chat. &lt;/p&gt;

&lt;p&gt;If &lt;code&gt;dokku&lt;/code&gt; does not appear under MCP tools, open the MCP panel and check the server logs: bad host, key path, or a failed &lt;code&gt;uv&lt;/code&gt; start show up there before any chat error does. &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%2F04h5bzzu4cpdan4nge1l.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%2F04h5bzzu4cpdan4nge1l.png" alt="Dokku MCP Cursor MCP Configuration&lt;br&gt;
" width="481" height="674"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Prompts that map cleanly to tools:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You type&lt;/th&gt;
&lt;th&gt;Tools the model should use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;“What apps are on my Dokku host?”&lt;/td&gt;
&lt;td&gt;&lt;code&gt;list_apps&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;“Is &lt;code&gt;api&lt;/code&gt; running, and what’s its public URL?”&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;app_report&lt;/code&gt;, &lt;code&gt;app_url&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;“Show the last 50 log lines for &lt;code&gt;api&lt;/code&gt;.”&lt;/td&gt;
&lt;td&gt;&lt;code&gt;app_logs&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;“Which env keys does &lt;code&gt;api&lt;/code&gt; have? Don’t show secrets.”&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;app_config&lt;/code&gt; (secrets stay masked)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Example of “What apps are on my Dokku host?”:&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%2F2y39ql27ouj6loet73oe.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%2F2y39ql27ouj6loet73oe.png" alt="Example of Dokku MCP in action on a Cursor chat&lt;br&gt;
" width="697" height="575"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You never open a terminal for those checks. SSH still happens, but inside the MCP server, on a shared session, with mode and allowlists applied.&lt;/p&gt;

&lt;p&gt;When you intentionally enable writes (&lt;code&gt;DOKKU_MCP_MODE=read-write&lt;/code&gt;), a prompt like “restart &lt;code&gt;api&lt;/code&gt;” only succeeds if the tool call includes &lt;code&gt;confirm=true&lt;/code&gt;. If the model omits it, the server refuses. That is the difference between a convenience wrapper and a controlled safety you would actually leave connected.&lt;/p&gt;




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

&lt;p&gt;dokku-mcp is a small, opinionated MCP server: FastMCP on the outside, Dokku over SSH on the inside, dedicated parsers instead of a free-form shell, and defaults that keep day-to-day use read-only.&lt;/p&gt;

&lt;p&gt;Issues and PRs are welcome, especially fixture captures from real Dokku versions and extra read-only reports that fit the “one command, one tool, one parser” rule. &lt;/p&gt;

&lt;p&gt;If you already run Dokku at home or in a small shop, this is a practical way to keep Dokku in the Cursor chat instead of a separate SSH session.&lt;/p&gt;




&lt;p&gt;Follow me on Twitter: &lt;a href="https://twitter.com/DevAsService" rel="noopener noreferrer"&gt;https://twitter.com/DevAsService&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on Instagram: &lt;a href="https://www.instagram.com/devasservice/" rel="noopener noreferrer"&gt;https://www.instagram.com/devasservice/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on TikTok: &lt;a href="https://www.tiktok.com/@devasservice" rel="noopener noreferrer"&gt;https://www.tiktok.com/@devasservice&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on YouTube: &lt;a href="https://www.youtube.com/@DevAsService" rel="noopener noreferrer"&gt;https://www.youtube.com/@DevAsService&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>dokku</category>
      <category>mcp</category>
      <category>fastmcp</category>
    </item>
    <item>
      <title>Your Voice Pipeline Is Too Flexible</title>
      <dc:creator>Developer Service</dc:creator>
      <pubDate>Thu, 03 Sep 2026 08:11:29 +0000</pubDate>
      <link>https://dev.to/devasservice/your-voice-pipeline-is-too-flexible-14p8</link>
      <guid>https://dev.to/devasservice/your-voice-pipeline-is-too-flexible-14p8</guid>
      <description>&lt;p&gt;Voice does not change what a tool needs. It changes what arrives first: audio, then a string, then a candidate payload that might still be wrong.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://developer-service.blog/your-data-structure-is-too-flexible/" rel="noopener noreferrer"&gt;Your Data Structure Is Too Flexible&lt;/a&gt;, a string crossed an HTTP boundary where the ledger expected an int. The stack trace blamed infrastructure. The bug was the handler that accepted the body.&lt;/p&gt;

&lt;p&gt;Voice puts another door in front of that one. A reservation tool still wants a contract. Speech-to-text gives you a transcript. That transcript is not a tool argument any more than a raw JSON dict is a charge request.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The rule:&lt;/strong&gt; Strict at the edges. A transcript is untrusted input. Only validated JSON crosses into the tool.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This piece follows one booking from mic to side effect: voice → transcribe → extract → validate → tool. &lt;/p&gt;

&lt;p&gt;The runnable code lives in this &lt;a href="https://github.com/nunombispo/structured-input-from-voice-article" rel="noopener noreferrer"&gt;repository&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Staging lied about the mic
&lt;/h2&gt;

&lt;p&gt;Every voice demo uses some version of this utterance:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Book me a table for four tomorrow at seven, outdoor if you have it.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Whisper returns a readable transcript. You paste it into a prompt that says “return JSON”. &lt;/p&gt;

&lt;p&gt;The model fills &lt;code&gt;party_size&lt;/code&gt;, &lt;code&gt;date&lt;/code&gt;, &lt;code&gt;time&lt;/code&gt;, and &lt;code&gt;seating&lt;/code&gt;. The booking lands. The pipeline looks finished.&lt;/p&gt;

&lt;p&gt;Your reservation tool never wanted readable text. It wanted:&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;party_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;
&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="n"&gt;seating&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;indoor&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;outdoor&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="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the extractor invents &lt;code&gt;guests: "four"&lt;/code&gt;, drops the date, or maps “patio” into &lt;code&gt;seating&lt;/code&gt;, the booking is wrong even when every word in the transcript was transcribed correctly.&lt;/p&gt;

&lt;p&gt;Staging did not lie about speech-to-text. It lied about the contract. The take was clean, the speaker was you, and nobody asked what happens when speech is partial, ambiguous, or wrong.&lt;/p&gt;

&lt;p&gt;Production gets a different speaker. They trail off before the date. They say “Friday” with no week in context. They correct themselves mid-sentence: “actually make that eight, not four”. The model fills a required slot with a guess, coerces “a few people” into &lt;code&gt;3&lt;/code&gt;, or passes a free-text blob into a typed argument.&lt;/p&gt;

&lt;p&gt;Those are not Word Error Rate (WER) failures. Whisper heard the words. The payload still does not match the tool. The bug is the same family as the flexible &lt;code&gt;dict&lt;/code&gt;: untrusted input crossed the door, and nothing checked the shape.&lt;/p&gt;

&lt;p&gt;Transcription quality is a UX problem. The boundary that protects the tool is the one that refuses the call until the payload validates.&lt;/p&gt;




&lt;h2&gt;
  
  
  Don’t ask one prompt to do two jobs
&lt;/h2&gt;

&lt;p&gt;The staging lie often hides in a single instruction: “Transcribe this audio and return JSON for the booking tool.”&lt;/p&gt;

&lt;p&gt;It works on a clean take. Under real speech it breaks in ways you cannot debug. The user corrects a time mid-utterance; the model has to choose between the transcript text and the fields it already filled. A required slot is missing; the model invents one to satisfy JSON rather than fail closed.&lt;/p&gt;

&lt;p&gt;When &lt;code&gt;party_size&lt;/code&gt; comes back wrong, you cannot tell whether Whisper misheard “four” or the extractor guessed. There is no stable artifact to re-run extraction against, and no clean place to validate before the tool call.&lt;/p&gt;

&lt;p&gt;Keep a handoff.&lt;/p&gt;

&lt;p&gt;Speech-to-text produces a &lt;code&gt;Transcript&lt;/code&gt;: text, plus optional confidence and timestamps. &lt;/p&gt;

&lt;p&gt;The tool never sees audio or raw STT output. &lt;/p&gt;

&lt;p&gt;If extraction fails, retry or clarify against the same transcript. If STT fails, fix recognition. Do not ask the schema to guess from noise.&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;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Transcript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;STT artifact. Text only — not a tool argument.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&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="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&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;ge&lt;/span&gt;&lt;span class="o"&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="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;timestamps&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="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&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;|&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;WER measures recognition accuracy. It does not measure whether the payload matches the tool. &lt;/p&gt;

&lt;p&gt;The candidate payload still needs an explicit contract: which fields, which types, what is required. &lt;/p&gt;

&lt;p&gt;That contract is the tool schema, the same Pydantic model extraction targets and validation checks against.&lt;/p&gt;




&lt;h2&gt;
  
  
  Name what each stage owns
&lt;/h2&gt;

&lt;p&gt;Follow one utterance through five stages. If a design review cannot name what each stage owns, the boundary will leak, and the leak will look like “the model is bad at voice”.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Voice&lt;/strong&gt; owns capture and audio quality. It does not own meaning or fields.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Transcribe&lt;/strong&gt; owns speech-to-text. It produces a &lt;code&gt;Transcript&lt;/code&gt;. WER belongs here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Extract&lt;/strong&gt; owns turning that string into a candidate payload shaped like the tool contract, usually with an LLM and PydanticAI. It may guess. It must not be the last gate before the tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validate&lt;/strong&gt; owns the schema: types, required fields, enums, constraints. On failure it rejects or returns structured errors. It does not “improve” the transcript or invent missing slots to be helpful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tool&lt;/strong&gt; owns side effects: booking, query, write. It receives only validated data, never a raw transcript, an untyped dict, or best-effort JSON from the model.&lt;/p&gt;

&lt;p&gt;In the &lt;a href="https://github.com/nunombispo/structured-input-from-voice-article" rel="noopener noreferrer"&gt;repository&lt;/a&gt;, that map is wired in &lt;code&gt;src/voice_structured/pipeline.py&lt;/code&gt;. Capture and transcribe produce a &lt;code&gt;Transcript&lt;/code&gt;. Extraction and validation produce a &lt;code&gt;ReservationRequest&lt;/code&gt;. The tool receives only what passed both gates.&lt;/p&gt;

&lt;p&gt;The invariant from the earlier article, applied twice: once at transcript → payload, once at payload → tool. Fail at the door with a message about the contract. Do not debug a wrong booking three layers down.&lt;/p&gt;




&lt;h2&gt;
  
  
  The contract is the spine
&lt;/h2&gt;

&lt;p&gt;Once transcription and extraction are separate, the tool schema does the same job &lt;code&gt;ChargeRequest&lt;/code&gt; did for inbound JSON.&lt;/p&gt;

&lt;p&gt;Write it as a Pydantic model: field names, types, required vs optional, and the constraints the tool will enforce anyway. &lt;/p&gt;

&lt;p&gt;Extraction targets that type. Validation checks against it. The tool re-validates before any side effect.&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;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&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;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReservationRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;party_size&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="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&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="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;
    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
    &lt;span class="n"&gt;seating&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;indoor&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;outdoor&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="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PydanticAI puts that contract on the agent. The transcript is the input. &lt;/p&gt;

&lt;p&gt;The agent’s job is to produce a &lt;code&gt;ReservationRequest&lt;/code&gt;, not free-form JSON. &lt;/p&gt;

&lt;p&gt;Relative dates such as “tomorrow” resolve against today in the system prompt, still extraction, not STT.&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;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic_ai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;voice_structured.schema&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ReservationRequest&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_reservation_agent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&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;ReservationRequest&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;today&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;today&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openai:gpt-4o-mini&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;output_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ReservationRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;retries&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;output&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;system_prompt&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;Extract reservation fields from the transcript. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Do not invent values for missing slots. &lt;/span&gt;&lt;span class="sh"&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;Today&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s date is &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;today&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Resolve relative dates such as &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;tomorrow&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; against that. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;party_size must be an integer. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seating is indoor, outdoor, or omit if unknown.&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent proposes a payload. The schema accepts or rejects it inside &lt;code&gt;run()&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;Output retries feed structured validation errors back to the model, not a brand-new free-form prompt.&lt;/p&gt;

&lt;p&gt;The side-effect tool receives validated JSON only:&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;from&lt;/span&gt; &lt;span class="n"&gt;voice_structured.schema&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ReservationRequest&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;book_table&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="nb"&gt;dict&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Side-effect tool. Receives validated JSON only — never a raw transcript.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="n"&gt;ReservationRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_validate&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&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;booked&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;reservation&lt;/span&gt;&lt;span class="sh"&gt;"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;handle_transcript&lt;/code&gt; in &lt;code&gt;pipeline.py&lt;/code&gt; is the orchestration point. &lt;/p&gt;

&lt;p&gt;Empty text refuses. STT confidence below &lt;code&gt;0.5&lt;/code&gt; clarifies and skips extraction. &lt;/p&gt;

&lt;p&gt;A valid &lt;code&gt;ReservationRequest&lt;/code&gt; is the only path that calls &lt;code&gt;book_table&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;When the output-retry budget is exhausted, PydanticAI raises &lt;code&gt;UnexpectedModelBehavior&lt;/code&gt;, catch it and clarify. Do not call the tool.&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;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_transcript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;transcript&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="n"&gt;Transcript&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;agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&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;ReservationRequest&lt;/span&gt;&lt;span class="p"&gt;]&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="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;PipelineResult&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;artifact&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;transcript&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Transcript&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nc"&gt;Transcript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;extractor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;build_reservation_agent&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;artifact&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;clarify_or_refuse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;artifact&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;artifact&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;artifact&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;LOW_CONFIDENCE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;clarify_or_refuse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;artifact&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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;extractor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;artifact&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;UnexpectedModelBehavior&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;clarify_or_refuse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;artifact&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="n"&gt;ReservationRequest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;book_table&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;model_dump&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;PipelineResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;outcome&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;booked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Reservation booked.&lt;/span&gt;&lt;span class="sh"&gt;"&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;payload&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 CLI &lt;code&gt;--transcript&lt;/code&gt; flag skips the mic so you can test extract → validate → tool as text. &lt;code&gt;LOW_CONFIDENCE&lt;/code&gt; lives in &lt;code&gt;transcribe.py&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;Tune it per domain and STT vendor; &lt;code&gt;0.5&lt;/code&gt; is a starting point for whole-utterance scores. &lt;/p&gt;

&lt;p&gt;The repository wires the model from &lt;code&gt;OPENAI_API_KEY&lt;/code&gt; / &lt;code&gt;VOICE_STRUCTURED_MODEL&lt;/code&gt;; the snippet above is the spine.&lt;/p&gt;




&lt;h2&gt;
  
  
  When speech is wrong
&lt;/h2&gt;

&lt;p&gt;The clean demo utterance is not the story. The story is what happens when the user says “book a table for four” and stops.&lt;/p&gt;

&lt;p&gt;Validation failure is normal with voice. The useful question is which kind you have, and whether you retry, clarify, or refuse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Partial:&lt;/strong&gt; a required slot was never spoken. “Book a table for four” with no date.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ambiguous:&lt;/strong&gt; the transcript has a value the schema cannot resolve. “Friday” with no week. “Around seven” with no &lt;code&gt;time&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wrong:&lt;/strong&gt; extraction or coercion produces an invalid payload. &lt;code&gt;party_size: "four"&lt;/code&gt;. &lt;code&gt;seating: "patio"&lt;/code&gt; outside the enum.&lt;/p&gt;

&lt;p&gt;Each maps to a different response at the boundary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retry extraction&lt;/strong&gt; inside &lt;code&gt;Agent(retries=…)&lt;/code&gt; when the speech might be mappable but the first pass guessed or dropped a field. PydanticAI feeds structured validation errors back to the LLM.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Clarify&lt;/strong&gt; when a required slot is genuinely missing or ambiguous. Ask for the date, the time, or seating. Keep the partial payload out of the tool until the user fills the gap. Voice UX usually tolerates one visible clarify round better than three silent retries. Cap the attempts and fail where the user can see it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Refuse&lt;/strong&gt; when the request cannot be satisfied safely: empty audio, repeated validation failure, nonsense, or a slot the tool cannot represent. Do not call the tool with a best-effort fill.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;build_reservation_agent()&lt;/code&gt; sets &lt;code&gt;retries={"output": 2}&lt;/code&gt;. When that budget dies, &lt;code&gt;handle_transcript&lt;/code&gt; catches &lt;code&gt;UnexpectedModelBehavior&lt;/code&gt; and routes to &lt;code&gt;clarify_or_refuse&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;Low STT confidence never reaches the extractor. Empty speech refuses immediately.&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;LOW_CONFIDENCE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;clarify_or_refuse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Transcript&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;PipelineResult&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&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="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;PipelineResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;outcome&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refuse&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No speech to extract. Refusing — the tool will not be called.&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;LOW_CONFIDENCE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;PipelineResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;outcome&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clarify&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;message&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;STT confidence is too low to coerce into typed fields. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Please repeat the date, time, party size, and seating.&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="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;PipelineResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;outcome&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clarify&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;message&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;Could not fill the reservation schema from this transcript. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Please give a date, a time, and party size &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(and indoor or outdoor if you care).&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;handle_transcript&lt;/code&gt; never calls &lt;code&gt;book_table&lt;/code&gt; on those paths.&lt;/p&gt;

&lt;p&gt;Low STT confidence on an utterance is a clarify signal, not a reason to coerce into a typed field.&lt;/p&gt;

&lt;p&gt;Log validation failures at the schema boundary: field paths (&lt;code&gt;date&lt;/code&gt;, &lt;code&gt;party_size&lt;/code&gt;), attempt count, and outcome. &lt;code&gt;src/voice_structured/observability.py&lt;/code&gt; has a minimal &lt;code&gt;log_boundary&lt;/code&gt; hook. &lt;/p&gt;

&lt;p&gt;That is how you debug a bad booking in production instead of chasing a stack trace inside the reservation service.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Install from the &lt;a href="https://github.com/nunombispo/structured-input-from-voice-article" rel="noopener noreferrer"&gt;repository&lt;/a&gt;: &lt;code&gt;pip install -e ".[dev]"&lt;/code&gt;, copy &lt;code&gt;.env.example&lt;/code&gt; to &lt;code&gt;.env&lt;/code&gt;, set &lt;code&gt;OPENAI_API_KEY&lt;/code&gt;. Full setup is in the README. The &lt;code&gt;--transcript&lt;/code&gt; flag skips capture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Happy path&lt;/strong&gt;: full utterance, validated booking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; voice_structured &lt;span class="nt"&gt;--transcript&lt;/span&gt; &lt;span class="s2"&gt;"Book me a table for four tomorrow at seven, outdoor if you have it."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;"outcome"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"booked"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Reservation booked."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"party_size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-02"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"19:00:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"seating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"outdoor"&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;Dates resolve against the day you run the command. The important part is &lt;code&gt;outcome: booked&lt;/code&gt; with a validated &lt;code&gt;ReservationRequest&lt;/code&gt;, the same object &lt;code&gt;book_table&lt;/code&gt; receives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Partial speech&lt;/strong&gt;: output retries exhaust, the pipeline clarifies, and &lt;code&gt;book_table&lt;/code&gt; is not called:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; voice_structured &lt;span class="nt"&gt;--transcript&lt;/span&gt; &lt;span class="s2"&gt;"Book a table for four."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;"outcome"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"clarify"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Could not fill the reservation schema from this transcript. Please give a date, a time, and party size (and indoor or outdoor if you care)."&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;No second JSON block means the tool was not called. That is the designed path for &lt;code&gt;"Book a table for four."&lt;/code&gt; and similar partial utterances. Live models may still invent missing slots despite the system prompt. Treat that as prompt tuning, not a reason to drop the schema gate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Low STT confidence&lt;/strong&gt;: extraction never runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; voice_structured &lt;span class="nt"&gt;--transcript&lt;/span&gt; &lt;span class="s2"&gt;"Book me a table for four tomorrow at seven."&lt;/span&gt; &lt;span class="nt"&gt;--confidence&lt;/span&gt; 0.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;"outcome"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"clarify"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"STT confidence is too low to coerce into typed fields. Please repeat the date, time, party size, and seating."&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;Again, no reservation payload.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this isn't
&lt;/h2&gt;

&lt;p&gt;This is not an argument against Whisper, or against asking a model to extract fields. Extraction is how you get from a string to a candidate. The point is that extraction is not the door.&lt;/p&gt;

&lt;p&gt;It is also not a voice-UX handbook. One clarify round, a confidence threshold, and a refuse path are enough to prove the design rule. They are not a dialogue manager.&lt;/p&gt;

&lt;p&gt;Skip the conclusion that every voice app needs this exact five-stage graph. Dicts, transcripts, and JSON blobs are fine as owned data after a boundary has already checked the shape. &lt;/p&gt;

&lt;p&gt;The problem is treating untrusted speech as if it were already a tool argument.&lt;/p&gt;




&lt;h2&gt;
  
  
  The rule, twice
&lt;/h2&gt;

&lt;p&gt;Voice adds a door. It does not replace the one you already needed.&lt;/p&gt;

&lt;p&gt;Map the pipeline so each stage owns one transformation. Keep transcription and extraction separate so you can re-run and debug. Define the tool contract in Pydantic and extract into it with PydanticAI. &lt;/p&gt;

&lt;p&gt;When speech is partial, ambiguous, or invalid, retry inside the agent, clarify with the user, or refuse.&lt;/p&gt;

&lt;p&gt;Never call the tool with a transcript, an untyped dict, or a payload that failed validation.&lt;/p&gt;

&lt;p&gt;Validated JSON at the tool boundary, every time.&lt;/p&gt;

&lt;p&gt;For HTTP and config boundaries, see &lt;a href="https://developer-service.blog/your-data-structure-is-too-flexible/" rel="noopener noreferrer"&gt;Your Data Structure Is Too Flexible&lt;/a&gt;. For voice, add one more door, and keep the same rule: validate, then move on.&lt;/p&gt;




&lt;p&gt;Follow me on Twitter: &lt;a href="https://twitter.com/DevAsService" rel="noopener noreferrer"&gt;https://twitter.com/DevAsService&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on Instagram: &lt;a href="https://www.instagram.com/devasservice/" rel="noopener noreferrer"&gt;https://www.instagram.com/devasservice/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on TikTok: &lt;a href="https://www.tiktok.com/@devasservice" rel="noopener noreferrer"&gt;https://www.tiktok.com/@devasservice&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on YouTube: &lt;a href="https://www.youtube.com/@DevAsService" rel="noopener noreferrer"&gt;https://www.youtube.com/@DevAsService&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;Photo by Namroud Gorguis / Unsplash&lt;/p&gt;

</description>
      <category>python</category>
      <category>whisper</category>
      <category>pydantic</category>
    </item>
    <item>
      <title>Your Data Structure Is Too Flexible</title>
      <dc:creator>Developer Service</dc:creator>
      <pubDate>Mon, 24 Aug 2026 13:19:07 +0000</pubDate>
      <link>https://dev.to/devasservice/your-data-structure-is-too-flexible-4fb8</link>
      <guid>https://dev.to/devasservice/your-data-structure-is-too-flexible-4fb8</guid>
      <description>&lt;p&gt;Python makes it easy to pass data around as dicts, nested JSON blobs, and “whatever the client sent”.&lt;/p&gt;

&lt;p&gt;That flexibility is useful when you own the shape. It gets expensive when the data crosses a trust boundary - an HTTP body, a webhook, a file from another team - and you treat it like it already matches your assumptions.&lt;/p&gt;

&lt;p&gt;You may already know &lt;a href="https://pydantic.dev/docs/validation/latest/get-started/" rel="noopener noreferrer"&gt;Pydantic&lt;/a&gt;. This article is not a tour of fields and validators.&lt;/p&gt;

&lt;p&gt;It is about a design rule that is easy to skip when the happy path works: validate at the boundary, then move on. &lt;/p&gt;

&lt;p&gt;Keep flexibility only where you chose it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Staging lied (inbound API JSON)
&lt;/h2&gt;

&lt;p&gt;Imagine a small HTTP handler that accepts a JSON body as a Python &lt;code&gt;dict&lt;/code&gt; and charges a user’s account. &lt;code&gt;ledger&lt;/code&gt; below is a stand-in for whatever typed client or service call sits deeper in your stack.&lt;/p&gt;

&lt;p&gt;In staging, every client you control sends the same clean shape, so the happy path is the only path you ever see.&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;charge_user&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="nb"&gt;dict&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="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&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;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="c1"&gt;# Somewhere deeper in the stack — expects an int user_id
&lt;/span&gt;    &lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;debit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Staging payload:&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="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&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="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="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&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;dev@example.com&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;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;19.99&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;That works. &lt;code&gt;user.id&lt;/code&gt; is an int, &lt;code&gt;amount&lt;/code&gt; is a float, and &lt;code&gt;ledger.debit&lt;/code&gt; never complains.&lt;/p&gt;

&lt;p&gt;Then production gets a request from a mobile client, a partner integration, or a retry queue that re-serialized the body. The JSON is still “valid”.&lt;/p&gt;

&lt;p&gt;The shape is almost right:&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="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&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="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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;42&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;email&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;buyer@example.com&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;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;19.99&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;Nothing fails in the handler. It forwards &lt;code&gt;user_id&lt;/code&gt; as whatever JSON gave it. &lt;/p&gt;

&lt;p&gt;The failure shows up later - in &lt;code&gt;ledger.debit&lt;/code&gt;, a SQL parameter binder, or a comparison that assumes an int. If that deeper call type-checks or binds &lt;code&gt;user_id&lt;/code&gt; as &lt;code&gt;int&lt;/code&gt;, a &lt;code&gt;str&lt;/code&gt; raises there. &lt;/p&gt;

&lt;p&gt;Worse: the wrong type slips into storage and you notice even later.&lt;/p&gt;

&lt;p&gt;The stack trace points at infrastructure code. The bug was at the door: &lt;code&gt;user.id&lt;/code&gt; crossed the trust boundary as a string, and a flexible &lt;code&gt;dict&lt;/code&gt; let it through.&lt;/p&gt;

&lt;p&gt;Staging did not lie about the feature. It lied about the contract.&lt;/p&gt;




&lt;h2&gt;
  
  
  The cost of flexible-by-default
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;dict&lt;/code&gt; is patient. It will carry a string where you meant an int, a missing nest where you meant a required object, or a &lt;code&gt;None&lt;/code&gt; where you meant a value.&lt;/p&gt;

&lt;p&gt;And it will do it without complaining at the boundary.&lt;/p&gt;

&lt;p&gt;Type annotations on the parameter help your editor and your teammates. They do not stop the wrong shape from entering the process.&lt;/p&gt;

&lt;p&gt;“Almost the right shape” is worse than obviously wrong data. Staging taught everyone what the payload &lt;em&gt;usually&lt;/em&gt; looks like.&lt;/p&gt;

&lt;p&gt;Optional fields, defaulted nests, and defensive &lt;code&gt;.get()&lt;/code&gt; calls then paper over the cases that don’t match. The contract becomes tribal knowledge instead of something the code can reject.&lt;/p&gt;

&lt;p&gt;The consequence arrives later. You debug three layers down while the handler that accepted the body already returned.&lt;/p&gt;

&lt;p&gt;Catching bad data at the door costs a validation error. Catching it downstream costs time, a misleading stack trace, and confidence in every other flexible boundary you still have.&lt;/p&gt;




&lt;h2&gt;
  
  
  The rule: strict at trust boundaries
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;ONE THING:&lt;/strong&gt; Strict at edges; flexibility only where you chose it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A trust boundary is any place data enters your process from something you do not fully control.&lt;/p&gt;

&lt;p&gt;For this article, that means inbound API JSON. The same idea applies to config files, environment variables, and messages from another service - not shown here.&lt;/p&gt;

&lt;p&gt;Inside the app, after the boundary has done its job, you can pass richer objects around, reshape data, or keep temporary dicts for a transform. That flexibility is earned. It is not the default for untrusted input.&lt;/p&gt;

&lt;p&gt;If you already use Pydantic and it still feels like boilerplate before the “real” code, flip the framing. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The model &lt;em&gt;is&lt;/em&gt; the contract.&lt;/strong&gt; &lt;/p&gt;

&lt;p&gt;The handler that runs after validation is the easy part, because the hard part already happened at the door.&lt;/p&gt;




&lt;h2&gt;
  
  
  One refactor
&lt;/h2&gt;

&lt;p&gt;Same payload, different door. Instead of accepting a naked &lt;code&gt;dict&lt;/code&gt;, define the contract once and validate before anything else 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;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ValidationError&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ChargeRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;
    &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;charge_user&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="nb"&gt;dict&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="n"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ChargeRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_validate&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="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;debit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Feed it the production body where &lt;code&gt;user.id&lt;/code&gt; was &lt;code&gt;"42"&lt;/code&gt;. In Pydantic v2’s default (lax) mode, that string is coerced to &lt;code&gt;int&lt;/code&gt; at the boundary. &lt;/p&gt;

&lt;p&gt;Strict mode would reject it instead - either way, the decision happens at the door. For this example: lax turns &lt;code&gt;"42"&lt;/code&gt; into &lt;code&gt;42&lt;/code&gt;; a value like &lt;code&gt;"not-an-id"&lt;/code&gt; fails in both modes:&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;ChargeRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_validate&lt;/span&gt;&lt;span class="p"&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;user&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="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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not-an-id&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;email&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;buyer@example.com&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;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;19.99&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="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;ValidationError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get a &lt;code&gt;ValidationError&lt;/code&gt; immediately, pointing at &lt;code&gt;user.id&lt;/code&gt;, before &lt;code&gt;ledger.debit&lt;/code&gt; is called. Trimmed, the message looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1 validation error for ChargeRequest
user.id
  Input should be a valid integer, unable to parse string as an integer
  [type=int_parsing, input_value='not-an-id', input_type=str]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By the time &lt;code&gt;ledger.debit&lt;/code&gt; runs, &lt;code&gt;request.user.id&lt;/code&gt; is already an int - the conversion happened at the boundary, not three layers down in a binder that was never meant to diagnose your API contract.&lt;/p&gt;

&lt;p&gt;That is the whole point: fail at the trust boundary, with a message about the field that broke the contract. In an HTTP API, map that to a &lt;strong&gt;400&lt;/strong&gt; or &lt;strong&gt;422&lt;/strong&gt; with the validation details, not a &lt;strong&gt;500&lt;/strong&gt; from deeper in the stack.&lt;/p&gt;

&lt;p&gt;If you use FastAPI, the framework can wire the same models to the request body for you. The design rule does not change, only who calls &lt;code&gt;model_validate&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this isn't
&lt;/h2&gt;

&lt;p&gt;Skip the conclusion that you should never use dicts. Dicts are fine for owned data, temporary transforms, and code that runs &lt;em&gt;after&lt;/em&gt; a boundary has already checked the shape. The problem is treating untrusted JSON as if it were already trusted.&lt;/p&gt;

&lt;p&gt;This article is also not a complete Pydantic course. You saw one nested model, &lt;code&gt;model_validate&lt;/code&gt;, and a &lt;code&gt;ValidationError&lt;/code&gt; at the door. That is enough to prove the design rule. It is not enough to cover custom validators, settings, serialization, or performance.&lt;/p&gt;

&lt;p&gt;TypedDict and dataclasses are not the enemy either. Those tools document and structure data in useful ways. They do not, by themselves, give you the same runtime contract check at a trust boundary. That check is the point of this article.&lt;/p&gt;




&lt;h2&gt;
  
  
  Go deeper
&lt;/h2&gt;

&lt;p&gt;If that design rule makes sense and you want the longer path - custom validators, nested models, settings, APIs, and how Pydantic compares to the alternatives - then read &lt;a href="https://leanpub.com/practical-pydantic" rel="noopener noreferrer"&gt;Practical Pydantic&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It is written for Python developers, FastAPI users, and anyone tired of bad data surviving staging and failing in production.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Keep the rule either way: validate at the trust boundary, then move on. Stay flexible only where you chose it.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Follow me on Twitter: &lt;a href="https://twitter.com/DevAsService" rel="noopener noreferrer"&gt;https://twitter.com/DevAsService&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on Instagram: &lt;a href="https://www.instagram.com/devasservice/" rel="noopener noreferrer"&gt;https://www.instagram.com/devasservice/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on TikTok: &lt;a href="https://www.tiktok.com/@devasservice" rel="noopener noreferrer"&gt;https://www.tiktok.com/@devasservice&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on YouTube: &lt;a href="https://www.youtube.com/@DevAsService" rel="noopener noreferrer"&gt;https://www.youtube.com/@DevAsService&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;Photo by Shubham Dhage / Unsplash&lt;/p&gt;

</description>
      <category>python</category>
      <category>pydantic</category>
      <category>data</category>
      <category>structures</category>
    </item>
    <item>
      <title>The Watchdog Pattern - From ESP32 to Python</title>
      <dc:creator>Developer Service</dc:creator>
      <pubDate>Mon, 03 Aug 2026 07:48:44 +0000</pubDate>
      <link>https://dev.to/devasservice/the-watchdog-pattern-from-esp32-to-python-k7h</link>
      <guid>https://dev.to/devasservice/the-watchdog-pattern-from-esp32-to-python-k7h</guid>
      <description>&lt;p&gt;The sensor node had been running for eleven days. The status LED blinked on schedule. &lt;code&gt;ping&lt;/code&gt; returned in under a millisecond. But the last reading in the database was from Tuesday. The MQTT client had stopped publishing, and the main loop was stuck waiting on a mutex that would never be released. The device had wedged.&lt;/p&gt;

&lt;p&gt;The fix: pull power, plug it back in. If you have spent time with embedded hardware on a bench or in the field, you have done this.&lt;/p&gt;

&lt;p&gt;Name the problem before reaching for the solution. The device did not crash. It entered a state where it could not make progress, and nothing outside the firmware had authority to change that. Silent failure. No recovery path.&lt;/p&gt;

&lt;p&gt;This is not an ESP32 problem. A Python worker that blocks on a dead database connection and never times out shows the same shape: the process exists, the supervisor thinks everything is fine, and the work stops. The MCU case is useful because the failure is physical, you can see the LED lie to you.&lt;/p&gt;

&lt;p&gt;Something external must detect that failure and force recovery. On a microcontroller, that mechanism has a name you already know. What is the equivalent when your code runs on a server instead of a chip?&lt;/p&gt;




&lt;h2&gt;
  
  
  Inside the ESP32 Watchdog
&lt;/h2&gt;

&lt;p&gt;The ESP32 watchdog is a hardware timer with a simple contract. You configure a timeout of say, five seconds. The timer counts down. Your firmware must reset it before the countdown reaches zero. If it does not, the hardware assumes the software has failed and resets the chip. No debugger required. No operator with a screwdriver.&lt;/p&gt;

&lt;p&gt;The ESP32 has two watchdogs, and they watch different things. The Task Watchdog Timer (TWDT) monitors FreeRTOS tasks. If a task stops running blocked on a mutex, stuck in an infinite loop, or starved by a higher-priority task, the TWDT fires. The Interrupt Watchdog Timer (IWDT) monitors interrupt service routines. If an ISR runs too long or never returns, the IWDT fires. In MicroPython, &lt;code&gt;machine.WDT&lt;/code&gt; wraps the task-level watchdog, you configure a timeout and call &lt;code&gt;feed()&lt;/code&gt; to reset the timer. ISR hangs are still caught by the IWDT underneath.&lt;/p&gt;

&lt;p&gt;Calling &lt;code&gt;wdt.feed()&lt;/code&gt; is not a fix. It is proof that your code reached a known-good point and is still making progress. On a wedged device, the main loop never gets there, it spins on a mutex. The watchdog never gets fed. The countdown reaches zero. The chip resets.&lt;/p&gt;

&lt;p&gt;That is the contract: something outside the running code must detect failure and force recovery. The watchdog is hardware enforcement of a rule your firmware cannot enforce on itself. A wedged task cannot un-wedge itself.&lt;/p&gt;

&lt;p&gt;This contract is not unique to firmware.&lt;/p&gt;




&lt;h2&gt;
  
  
  Hope Is Not a Strategy
&lt;/h2&gt;

&lt;p&gt;Hope is assuming your code keeps making progress because it has not crashed yet. The firmware is still running. The process still exists. The status light still blinks. Nothing has reported a failure, so you assume everything is fine.&lt;/p&gt;

&lt;p&gt;That assumption fails the same way on a chip and on a server. Firmware can block on I/O, lose a task, or spin in a loop while looking healthy from the outside. A Python service can hang on a dead dependency while the supervisor still reports up. In both cases the system appears alive, and nothing external has authority to disagree. You are back to the power-cycle strategy, waiting for a human to notice.&lt;/p&gt;

&lt;p&gt;Hope is not supervision. Supervision assumes failure will happen and plans for something outside the code to detect it and respond. Hope assumes failure will not happen, or that you will notice in time to fix it yourself.&lt;/p&gt;

&lt;p&gt;Firmware cannot un-wedge itself. A hung worker cannot restart itself. The next section shows what external supervision looks like in code.&lt;/p&gt;




&lt;h2&gt;
  
  
  Build It: ESP32 and Python
&lt;/h2&gt;

&lt;p&gt;The pattern is the same in both cases: monitor progress, enforce a timeout, recover when the timeout fires. Here are minimal pieces: one on the chip, the rest on the server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the ESP32:&lt;/strong&gt;&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;from&lt;/span&gt; &lt;span class="n"&gt;machine&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;WDT&lt;/span&gt;

&lt;span class="n"&gt;wdt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;WDT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;read_sensor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;publish_reading&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;wdt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;# Uncomment to simulate a hang — the chip resets in ~5 seconds:
&lt;/span&gt;    &lt;span class="c1"&gt;# while True: pass
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start the watchdog. Do work. Feed it at the end of each loop iteration. If &lt;code&gt;read_sensor()&lt;/code&gt; blocks forever or the hang line runs, &lt;code&gt;wdt.feed()&lt;/code&gt; never runs. The hardware timer expires and the chip resets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On a server:&lt;/strong&gt;&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;subprocess&lt;/span&gt;

&lt;span class="n"&gt;TIMEOUT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;

&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;proc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Popen&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;python&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;worker.py&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;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;TIMEOUT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TimeoutExpired&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;kill&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;  &lt;span class="c1"&gt;# hung — kill and restart
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;returncode&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;  &lt;span class="c1"&gt;# bad exit — restart
&lt;/span&gt;    &lt;span class="k"&gt;break&lt;/span&gt;  &lt;span class="c1"&gt;# clean exit — stop supervising
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The outer loop is the supervisor. It starts the worker, waits up to 30 seconds, and kills it if the worker hangs. A worker that exits with a non-zero return code gets restarted too; a clean exit ends supervision. &lt;/p&gt;

&lt;p&gt;The loop above shows the shape; in production you would probably use &lt;code&gt;systemd&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In production (systemd):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Sensor worker&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/python /opt/app/worker.py&lt;/span&gt;
&lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;on-failure&lt;/span&gt;
&lt;span class="py"&gt;WatchdogSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;30&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;notify&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Restart=on-failure&lt;/code&gt; handles exits. &lt;code&gt;WatchdogSec=&lt;/code&gt; handles hangs, but only if the worker sends &lt;code&gt;WATCHDOG=1&lt;/code&gt; to systemd via &lt;code&gt;sd_notify&lt;/code&gt; on a schedule. No heartbeat, no hang detection. &lt;/p&gt;

&lt;p&gt;Install &lt;code&gt;systemd-python&lt;/code&gt; via pip, or use your distro's &lt;code&gt;python3-systemd&lt;/code&gt; package:&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;systemd.daemon&lt;/span&gt;

&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;do_work&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;systemd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;daemon&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WATCHDOG=1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;For HTTP services (FastAPI):&lt;/strong&gt;&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="nd"&gt;@app.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;/health&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;health&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT 1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# prove the dependency is alive, not just the process
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&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;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;For Celery workers:&lt;/strong&gt;&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;task_time_limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;  &lt;span class="c1"&gt;# seconds — kill hung tasks
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Inside the worker (asyncio):&lt;/strong&gt;&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;asyncio&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;reading&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sensor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;5.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reading&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;asyncio.wait_for&lt;/code&gt; is an in-process timeout, it refuses to wait forever on a blocked call. It does not restart the process; it raises &lt;code&gt;TimeoutError&lt;/code&gt; so your code can log, retry, or exit and let the supervisor restart. &lt;/p&gt;

&lt;p&gt;External supervision and internal timeouts stack: the worker handles blocked calls, the supervisor handles a wedged worker.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;ESP32&lt;/th&gt;
&lt;th&gt;Python&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hang timeout&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WDT(timeout=5000)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;WatchdogSec=30&lt;/code&gt; (production); subprocess loop shows the same shape&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;In-process timeout&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;&lt;code&gt;asyncio.wait_for(...)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Timeout trigger&lt;/td&gt;
&lt;td&gt;hardware countdown&lt;/td&gt;
&lt;td&gt;systemd timer or supervisor kill&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recovery&lt;/td&gt;
&lt;td&gt;chip reset&lt;/td&gt;
&lt;td&gt;systemd restart or &lt;code&gt;proc.kill()&lt;/code&gt; + restart&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is deliberately minimal. No logging, exponential backoff, or alerting. Those matter, but they are not the contract. The contract is that something outside the worker enforces recovery.&lt;/p&gt;

&lt;p&gt;Working recovery is not the same as good recovery.&lt;/p&gt;




&lt;h2&gt;
  
  
  When Recovery Becomes the Problem
&lt;/h2&gt;

&lt;p&gt;A watchdog that resets on schedule is not a healthy device. It is a device that fails on schedule.&lt;/p&gt;

&lt;p&gt;The wedged mutex from the opening would have triggered a reset and then wedged again on the next boot, assuming the bug survived the restart. The ESP32 comes back, runs the same initialization, hits the same mutex, and stops feeding the watchdog again. Reset. Boot. Wedge. Reset. You have traded a silent failure for a noisy one, and the field log shows a device that reboots every five seconds without ever publishing a reading.&lt;/p&gt;

&lt;p&gt;The same loop appears in Python. A systemd unit with &lt;code&gt;Restart=always&lt;/code&gt; and a worker that crashes on startup will enter a crash loop, &lt;code&gt;systemctl&lt;/code&gt; reports active (restarting), but no work completes. Celery eventually replaces the worker process, but if the database is still down, the new worker blocks in the same place. The supervisor did its job. The root cause did not move.&lt;/p&gt;

&lt;p&gt;Supervision without observability makes this worse. If you do not log why recovery fired, the reset cause, a backtrace, or a metric, then you only know the device came back. You do not know why it reset.&lt;/p&gt;

&lt;p&gt;A watchdog that fires too often becomes noise, operators tune it out. One that fires too late means damage is already done: a motor left running, a valve stuck open, a buffer overwritten before the timeout expired.&lt;/p&gt;

&lt;p&gt;Recovery is necessary. It is not sufficient. You still need to fix the mutex, fix the connection string, add the timeout that prevents the hang in the first place. The watchdog buys you uptime. It does not buy you correctness.&lt;/p&gt;




&lt;h2&gt;
  
  
  One Pattern, Two Domains
&lt;/h2&gt;

&lt;p&gt;Every supervision design answers three questions. What are you monitoring? What triggers recovery? What happens after the reset?&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;ESP32 (MicroPython)&lt;/th&gt;
&lt;th&gt;Python (production)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What to monitor?&lt;/td&gt;
&lt;td&gt;Main loop progress: did &lt;code&gt;wdt.feed()&lt;/code&gt; run on schedule?&lt;/td&gt;
&lt;td&gt;Process health: &lt;code&gt;/health&lt;/code&gt; endpoint, internal heartbeat (&lt;code&gt;sd_notify&lt;/code&gt; / &lt;code&gt;WatchdogSec=&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What triggers recovery?&lt;/td&gt;
&lt;td&gt;Hardware timer expires without a feed&lt;/td&gt;
&lt;td&gt;Supervisor timeout, failed health check, hung task limit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What happens after reset?&lt;/td&gt;
&lt;td&gt;Chip reboots; check &lt;code&gt;machine.reset_cause()&lt;/code&gt;, log to flash&lt;/td&gt;
&lt;td&gt;Process restarts; capture exit code, log backtrace, alert if crash loop&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Apply it to the wedged sensor node. A five-second WDT would have caught the hang, no operator, no power cycle. But recovery alone would have produced a reboot loop until someone fixed the mutex bug and added logging to read the reset cause on boot. The checklist covers detection and recovery. Your code still has to stop causing it.&lt;/p&gt;

&lt;p&gt;If you build embedded systems, you already live this pattern. You feed watchdogs, read reset reasons, tune timeouts around your slowest legitimate operation. Python production is the same lesson in different clothes: external supervision, enforced timeouts, observable recovery. The platform changes. The contract does not.&lt;/p&gt;




&lt;p&gt;Follow me on Twitter: &lt;a href="https://twitter.com/DevAsService" rel="noopener noreferrer"&gt;https://twitter.com/DevAsService&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on Instagram: &lt;a href="https://www.instagram.com/devasservice/" rel="noopener noreferrer"&gt;https://www.instagram.com/devasservice/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on TikTok: &lt;a href="https://www.tiktok.com/@devasservice" rel="noopener noreferrer"&gt;https://www.tiktok.com/@devasservice&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on YouTube: &lt;a href="https://www.youtube.com/@DevAsService" rel="noopener noreferrer"&gt;https://www.youtube.com/@DevAsService&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;Photo by HAYOUNG CHO / Unsplash&lt;/p&gt;

</description>
      <category>micropython</category>
      <category>python</category>
      <category>watchdog</category>
    </item>
    <item>
      <title>Your Next Breach Won't Be a Hacker. It'll Be an Agent</title>
      <dc:creator>Developer Service</dc:creator>
      <pubDate>Mon, 27 Jul 2026 07:55:51 +0000</pubDate>
      <link>https://dev.to/devasservice/your-next-breach-wont-be-a-hacker-itll-be-an-agent-a0i</link>
      <guid>https://dev.to/devasservice/your-next-breach-wont-be-a-hacker-itll-be-an-agent-a0i</guid>
      <description>&lt;p&gt;I opened a terminal, pointed an AI agent at a freshly deployed API, and gave it one instruction: &lt;em&gt;"Find a way to access another user's data. You have no credentials and no documentation"&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;No exploit code. No CVE list. No hints. Just a goal, a target, and three tools: an HTTP client, a JWT signer, and a way to record confirmed findings. Ninety-one seconds later, it had done exactly what I asked.&lt;/p&gt;

&lt;p&gt;This wasn't a red team engagement. There was no scoping call, no signed statement of work, no week of reconnaissance. Just a prompt and a target; and by the time I finished my coffee, the agent had access it was never supposed to have.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Setup: A Real API, A Sandboxed Blast Radius
&lt;/h2&gt;

&lt;p&gt;I didn't point the agent at a known security-training app. &lt;a href="https://owasp.org/www-project-juice-shop/" rel="noopener noreferrer"&gt;Juice Shop&lt;/a&gt; and &lt;a href="https://owasp.org/www-project-crapi/" rel="noopener noreferrer"&gt;crAPI&lt;/a&gt; are great for learning, but they invite an easy dismissal: "of course it found bugs, that app is built to have them". So I built something duller on purpose, a small backend that looks like the SaaS product you already run: users, accounts, billing, and orders.&lt;/p&gt;

&lt;p&gt;Under the hood it's a FastAPI service backed by Postgres, probed by a PydanticAI agent running Claude Sonnet 4.6. It has no exposed OpenAPI or Swagger documentation (&lt;code&gt;docs_url=None&lt;/code&gt;, &lt;code&gt;redoc_url=None&lt;/code&gt;, &lt;code&gt;openapi_url=None&lt;/code&gt;) so the agent had to discover the API's shape by probing it, the same way an attacker would approach a production system with no public docs.&lt;/p&gt;

&lt;p&gt;The authorization bug looks like ordinary SaaS code. Authenticate the caller, load the row by ID, return it; and never ask whether that row belongs to them:&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="nd"&gt;@router.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;/{user_id}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;UserOut&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;read_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;CurrentUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_current_user&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&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;user&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&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;HTTPException&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;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User not found&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="n"&gt;user&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no check that &lt;code&gt;user_id == current_user.id&lt;/code&gt;. Any authenticated caller who guesses an ID gets the full row; including, via &lt;code&gt;UserOut&lt;/code&gt;, the bcrypt &lt;code&gt;password_hash&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The agent side is equally spare: one system prompt, Claude Sonnet 4.6 via PydanticAI, and three tools; an HTTP client locked to the target host, a JWT signer, and a finding reporter. The full prompt and tool implementations are in the &lt;a href="https://github.com/nunombispo/ai-agent-hacker" rel="noopener noreferrer"&gt;demo repo&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I planted three common API flaws up front: &lt;a href="https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/" rel="noopener noreferrer"&gt;Broken Object Level Authorization / BOLA (OWASP API1:2023)&lt;/a&gt;, &lt;a href="https://owasp.org/API-Security/editions/2023/en/0xa2-broken-authentication/" rel="noopener noreferrer"&gt;Broken Authentication (API2:2023)&lt;/a&gt;, and &lt;a href="https://owasp.org/API-Security/editions/2023/en/0xa3-broken-object-property-level-authorization/" rel="noopener noreferrer"&gt;Excessive Data Exposure (API3:2023)&lt;/a&gt;. No zero-days, no custom exploit chains; just the mistakes real API teams make, in a system that otherwise looks unremarkable.&lt;/p&gt;

&lt;p&gt;All of this ran inside an isolated Docker Compose network with no route to the public internet. Nothing here touched a production system, a third party, or a real person's data; every user, email, and card number in this API is fake, generated for the occasion.&lt;/p&gt;




&lt;h2&gt;
  
  
  Turning the Agent Loose
&lt;/h2&gt;

&lt;p&gt;I didn't give the agent a playbook. I gave it a role, a goal, and those three tools, nothing else. Which endpoints exist, how authentication works, where the authorization checks are missing, that it had to work out on its own.&lt;/p&gt;

&lt;p&gt;The first thing it did was reconnaissance, not exploitation. It fired parallel requests at &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;/api&lt;/code&gt;, and &lt;code&gt;/health&lt;/code&gt;. Only &lt;code&gt;/health&lt;/code&gt; returned 200. Then it started guessing: &lt;code&gt;/users&lt;/code&gt;, &lt;code&gt;/auth&lt;/code&gt;, &lt;code&gt;/login&lt;/code&gt;, &lt;code&gt;/register&lt;/code&gt;; all 404 until it hit &lt;code&gt;/auth/register&lt;/code&gt; and got a 422 back telling it the body needed &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, and &lt;code&gt;password&lt;/code&gt;. No documentation. Just an error message worth reading.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Excellent! Found working endpoints! Registration needs &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, and &lt;code&gt;password&lt;/code&gt;. Login needs &lt;code&gt;email&lt;/code&gt; and &lt;code&gt;password&lt;/code&gt;. Let me register users and explore."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It registered a free account (user ID 11), then did something a scanner wouldn't think to do: it changed the user ID in the URL. With its own token, it requested &lt;code&gt;/users/10&lt;/code&gt; and got back a full profile including a bcrypt &lt;code&gt;password_hash&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Found a critical IDOR! The attacker (user 11) can access &lt;code&gt;/users/10&lt;/code&gt; (the victim's data) including their &lt;code&gt;password_hash&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, and &lt;code&gt;name&lt;/code&gt;. But let me dig deeper — there may be more data to access."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;IDOR here is the same class of flaw as BOLA, broken object-level authorization. That last line matters more than the label. The agent didn't stop at the first win. It kept probing &lt;code&gt;/users/1&lt;/code&gt;, &lt;code&gt;/orders&lt;/code&gt;, &lt;code&gt;/orders/1&lt;/code&gt;, until it had enough evidence to report.&lt;/p&gt;

&lt;p&gt;The whole run took ninety-one seconds. The interesting part isn't the headline ("it found a BOLA"). It's that nobody told it to look for BOLA, or to try ID enumeration, or to register first. It reasoned its way there from a health check and a validation error.&lt;/p&gt;




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

&lt;p&gt;Let's see the actual traces that the agent found:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /users/10
Authorization: Bearer [token for user 11]

→ 200 {"id":10,"email":"victim@test.com","name":"Victim User",
       "password_hash":"$2b$12$slGb...[redacted]", ...}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No ownership check. Valid token; wrong user ID in the URL.&lt;/p&gt;

&lt;p&gt;It then walked lower IDs. &lt;code&gt;GET /users/1&lt;/code&gt; returned a seeded account that predated the run, Alex Rivera (&lt;code&gt;victim@example.com&lt;/code&gt;), the same flaw against a customer-shaped row from seed data, not the account the agent had just registered.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /orders/1
Authorization: Bearer [token for user 11]

→ 200 {"id":1,"item_description":"Annual plan upgrade", ...,
       "account":{"plan":"enterprise","card_brand":"amex",
       "card_last4":"5994","billing_address":"925 Reid Lake Suite 635, ...",
       "internal_notes":"[redacted]"}}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;User 11's token. Order 1 belongs to user 1. Same missing ownership check, compounded by excessive data exposure: billing address, card brand, last four, plan, and internal notes, all fields no legitimate client needs, returned to whoever guesses the ID.&lt;/p&gt;

&lt;p&gt;If you're a CTO, that isn't a bug ticket. Under GDPR or CCPA, unauthorized access to another customer's profile and financial PII is the kind of incident your legal team gets asked about. Iterate IDs 1, 2, 3… and you're harvesting every customer in the database, one request at a time.&lt;/p&gt;

&lt;p&gt;I'd also planted a broken-authentication flaw: a JWT signing secret leaked through verbose error handling (API2). This agent run didn't find it. Two out of three in ninety-one seconds, with no hints about where to look. That's a very good scorecard.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Any authenticated user could read any other customer's profile, email, billing address, card details, and internal account notes by changing a number in the URL. No zero-day. No stolen credentials. Ninety-one seconds.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Why This Changes the Economics of Attack
&lt;/h2&gt;

&lt;p&gt;Regulatory risk is the headline here. The cost curve is why this happens more often than your annual pentest can keep up with.&lt;/p&gt;

&lt;p&gt;A skilled human pentester finding what this agent found is not just plausible, but expected; after all, that's their job.&lt;/p&gt;

&lt;p&gt;This run took &lt;strong&gt;ninety-one seconds&lt;/strong&gt; of wall-clock time from agent start to reported findings, and cost &lt;strong&gt;roughly fifty cents&lt;/strong&gt; in Anthropic API usage for that transcript. A senior consultant doing the same work, blind recon, registering a test account, walking object IDs, documenting two critical findings with proof; is often a &lt;strong&gt;half-day minimum&lt;/strong&gt;, and at boutique AppSec day rates we've all seen in the $2,000–$5,000 range, you're not comparing cents to cents. You're comparing a coffee break to a line item on a purchase order.&lt;/p&gt;

&lt;p&gt;The cost of finding this in production is a breach notification. The cost of finding it in staging is fifty cents and ninety-one seconds.&lt;/p&gt;

&lt;p&gt;And the agent doesn't get tired, doesn't bill hourly, and doesn't need a scoping call. Point it at your staging environment on every deploy, scoped hosts, read-only constraints, no destructive actions, and an NDA if someone outside your team is running it. Run ten of them in parallel against ten API versions. The marginal cost of the next test approaches zero.&lt;/p&gt;

&lt;p&gt;That math doesn't just favor defenders who adopt this first. It favors attackers too. The same PydanticAI-and-Claude setup that ran against my demo API is available to anyone with an API key and a target URL. The capability that took ninety-one seconds in my sandbox doesn't require a security team, a budget approval, or a signed SOW. It requires patience and persistence, two things an autonomous agent has in unlimited supply.&lt;/p&gt;




&lt;h2&gt;
  
  
  What To Actually Do About It
&lt;/h2&gt;

&lt;p&gt;This isn't a call to rip out your stack or hire an AI security team overnight. It's a call to change your testing cadence before someone else's agent changes it for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Put AI-agent testing in CI, on every meaningful deploy.&lt;/strong&gt; That is the main move. Agentic red-teaming means pointing an autonomous AI agent at your API with a goal and a written scope, no scripted attack playbook, and letting it probe the way an attacker would. &lt;/p&gt;

&lt;p&gt;Picture the pipeline step: a deploy to staging completes, the agent gets the base URL and scope, it runs for a few minutes, and any confirmed finding opens as a ticket, same cadence as your test suite, a different class of failure. A once-a-year pentest was already too slow for how fast APIs ship; against an attacker who can spin up an agent in minutes, you need something running between engagements.&lt;/p&gt;

&lt;p&gt;Who owns it: security sets the goal and the rules; platform or DevOps wires it into CI. If those aren't two teams at your company, it's still two hats; don't leave it as an unspoken "someone should".&lt;/p&gt;

&lt;p&gt;A written scope can be short. Something like: &lt;em&gt;Target only &lt;code&gt;https://staging.example.com&lt;/code&gt;. Register and authenticate if the API allows it. Do not call production, do not mutate or delete data, do not leave the staging host. Goal: find a way to read another user's data; report only with reproducible request/response proof.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You don't need a vendor to start Monday:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Audit every &lt;code&gt;GET /…/{id}&lt;/code&gt; (and the POST/PATCH equivalents) for an ownership check before the row goes out and strip response fields your UI never shows.&lt;/li&gt;
&lt;li&gt;Clone the &lt;a href="https://github.com/nunombispo/ai-agent-hacker" rel="noopener noreferrer"&gt;demo agent&lt;/a&gt;, point it at staging under a scope like the one above, and treat the first confirmed finding as a real ticket.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The rest is hygiene that makes that pipeline worth running: BOLA and excessive data exposure show up constantly in API assessments, including this one, and they're usually cheap to fix once you look. Budget for continuous testing the way you budget for monitoring and backups. &lt;/p&gt;

&lt;p&gt;If you wouldn't bet your customer data on "nobody will think to enumerate IDs", don't bet it on "nobody has an API key".&lt;/p&gt;




&lt;h2&gt;
  
  
  Bottom Line
&lt;/h2&gt;

&lt;p&gt;I ran this exact experiment against a custom demo API in ninety-one seconds of wall-clock time. It cost roughly fifty cents in model API fees for that run. It found two critical authorization flaws that would have triggered a breach notification if this had been production.&lt;/p&gt;

&lt;p&gt;Your API is not a training app. But the agent doesn't know the difference and neither does an attacker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://developer-service.blog/work-with-me/" rel="noopener noreferrer"&gt;Book a free AI-agent security assessment →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I point the same technique at your staging or pre-production environment, safely, under NDA, with scoped hosts and no destructive actions and a full transcript of what the agent tried and what it found. You get a prioritized findings report. You get proof, not a slide deck.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Want the full agent transcript and demo code? &lt;a href="https://github.com/nunombispo/ai-agent-hacker" rel="noopener noreferrer"&gt;See the experiment on GitHub →&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Follow me on Twitter: &lt;a href="https://twitter.com/DevAsService" rel="noopener noreferrer"&gt;https://twitter.com/DevAsService&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on Instagram: &lt;a href="https://www.instagram.com/devasservice/" rel="noopener noreferrer"&gt;https://www.instagram.com/devasservice/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on TikTok: &lt;a href="https://www.tiktok.com/@devasservice" rel="noopener noreferrer"&gt;https://www.tiktok.com/@devasservice&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on YouTube: &lt;a href="https://www.youtube.com/@DevAsService" rel="noopener noreferrer"&gt;https://www.youtube.com/@DevAsService&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;Photo by &lt;a href="https://unsplash.com/@hidd3n?utm_source=ghost&amp;amp;utm_medium=referral&amp;amp;utm_campaign=api-credit" rel="noopener noreferrer"&gt;Kevin Horvat&lt;/a&gt; / &lt;a href="https://unsplash.com/?utm_source=ghost&amp;amp;utm_medium=referral&amp;amp;utm_campaign=api-credit" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>agents</category>
      <category>claude</category>
    </item>
    <item>
      <title>Building a Weather Home Screen on a Reflective LCD (MicroPython, ESP32-S3)</title>
      <dc:creator>Developer Service</dc:creator>
      <pubDate>Wed, 15 Jul 2026 08:39:39 +0000</pubDate>
      <link>https://dev.to/devasservice/building-a-weather-home-screen-on-a-reflective-lcd-micropython-esp32-s3-kfo</link>
      <guid>https://dev.to/devasservice/building-a-weather-home-screen-on-a-reflective-lcd-micropython-esp32-s3-kfo</guid>
      <description>&lt;p&gt;You know the moment. The board arrives. You flash &lt;a href="https://micropython.org/" rel="noopener noreferrer"&gt;MicroPython&lt;/a&gt;. You run &lt;code&gt;print("hello")&lt;/code&gt; and the serial console cheers. Then try the display, the whole reason you bought this thing, and… nothing. Or worse: a white rectangle that flickers once and dies.&lt;/p&gt;

&lt;p&gt;I've been there more times than I'd like to admit. The &lt;a href="https://www.waveshare.com/esp32-s3-rlcd-4.2.htm" rel="noopener noreferrer"&gt;Waveshare ESP32-S3 4.2" Reflective LCD&lt;/a&gt; is a gorgeous panel for a weather station: sunlight-readable, low power, no backlight glow at 2 a.m. But it's also a niche combo, ESP32-S3 plus an ST7305 controller on SPI, and the ecosystem doesn't hand you an &lt;code&gt;import display&lt;/code&gt; and walk away.&lt;/p&gt;

&lt;p&gt;This post is a &lt;strong&gt;weekend walkthrough&lt;/strong&gt; to get the ST7305 driver working, connect WiFi, pull live weather from &lt;a href="https://open-meteo.com/" rel="noopener noreferrer"&gt;Open-Meteo&lt;/a&gt;, and have something useful on the display by Sunday.&lt;/p&gt;

&lt;p&gt;All the source code is in &lt;a href="https://github.com/nunombispo/waveshare-rlcd-weather-article" rel="noopener noreferrer"&gt;this repository&lt;/a&gt;, flat Python files, no framework. Copy them to the board, add your WiFi credentials, reset.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Just want it working?&lt;/strong&gt; &lt;a href="http://developer-service.blog/shop" rel="noopener noreferrer"&gt;Quiet Forecast&lt;/a&gt; is ready-to-flash multi-screen firmware for this board. Everything below is the free weekend walk-through if you'd rather build it yourself.&lt;/p&gt;




&lt;h2&gt;
  
  
  Friday night: pixels
&lt;/h2&gt;

&lt;p&gt;Clone the repo, you don't type &lt;code&gt;st7305.py&lt;/code&gt; from scratch unless you want to. The sections below explain how the driver works;&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this board
&lt;/h3&gt;

&lt;p&gt;Reflective LCDs are not TFTs. Everything is monochrome, so design for contrast and big type, not gradients. That constraint is a feature for a wall or desk display: readable in daylight, pleasant at night, low power.&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%2F2zh5mrrung9a3goh75kb.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%2F2zh5mrrung9a3goh75kb.jpg" alt="Waveshare ESP32-S3 4.2" width="593" height="485"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Most tutorials for this kit stop at Arduino examples. There is &lt;strong&gt;no official MicroPython driver for the ST7305 on this board&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Building &lt;code&gt;st7305.py&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;So &lt;code&gt;st7305.py&lt;/code&gt; is a port. The init sequence, register values, delays, SPI command/data toggling, comes straight from the vendor BSP (Board Support Package). The pixel format does not.&lt;/p&gt;

&lt;p&gt;Reflective panels pack pixels in a peculiar layout: four vertical rows are interleaved in each byte column. Getting &lt;code&gt;(x, y)&lt;/code&gt; wrong doesn't produce a shifted image; it produces static. The core function is &lt;code&gt;_rlcd_pixel()&lt;/code&gt;, which maps logical coordinates into that buffer layout, with Y inverted to match the panel's scanning direction:&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;_rlcd_pixel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;inv_y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;
    &lt;span class="n"&gt;block_y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;inv_y&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="n"&gt;local_y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;inv_y&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
    &lt;span class="n"&gt;byte_x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="n"&gt;local_x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="n"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;byte_x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;h4&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;block_y&lt;/span&gt;
    &lt;span class="n"&gt;mask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;local_y&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;local_x&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;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;mask&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;buf&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;mask&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On top of that buffer we implement the primitives every UI needs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;fill&lt;/code&gt;, &lt;code&gt;pixel&lt;/code&gt;, &lt;code&gt;hline&lt;/code&gt;, &lt;code&gt;rect&lt;/code&gt;, &lt;code&gt;circle&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;text&lt;/code&gt; via &lt;code&gt;framebuf&lt;/code&gt; 8×8 glyphs, scaled to 8 / 16 / 24 / 32 px heights&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;refresh()&lt;/code&gt;, which push the full buffer over SPI in chunks (&lt;code&gt;_SPI_CHUNK = 4092&lt;/code&gt;) to avoid allocation spikes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;display.py&lt;/code&gt; wraps this in a small &lt;code&gt;Display&lt;/code&gt; class: margins, header/footer heights, and &lt;code&gt;content_area()&lt;/code&gt; so pages never hard-code like &lt;code&gt;y = 48&lt;/code&gt;. The ST7305 import is deferred inside &lt;code&gt;_create_panel()&lt;/code&gt; to keep boot-time RAM lower:&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="nd"&gt;@staticmethod&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_create_panel&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;spi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SPI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LCD_SPI_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;baudrate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1_000_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;st7305&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ST7305&lt;/span&gt;  &lt;span class="c1"&gt;# defer heavy import
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ST7305&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rst&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;content_area&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MARGIN&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HEADER_H&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CONTENT_GAP&lt;/span&gt;
    &lt;span class="n"&gt;bottom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HEIGHT&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MARGIN&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FOOTER_H&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CONTENT_GAP&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MARGIN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WIDTH&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MARGIN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bottom&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you make it through Friday with a filled rectangle and readable text on the display, you've cleared the hardest hurdle.&lt;/p&gt;




&lt;h2&gt;
  
  
  Saturday morning: structure
&lt;/h2&gt;

&lt;p&gt;Pixels work. Now make the code survivable.&lt;/p&gt;

&lt;p&gt;Big firmware projects tend to grow into &lt;code&gt;services/&lt;/code&gt;, &lt;code&gt;drivers/&lt;/code&gt;, &lt;code&gt;pages/&lt;/code&gt;, schedulers, event buses. That's the right shape when you have twelve screens and three I2C sensors.&lt;/p&gt;

&lt;p&gt;For a first weekend with a new board, it's overkill.&lt;/p&gt;

&lt;p&gt;This project uses a &lt;strong&gt;flat layout&lt;/strong&gt; with just enough separation to read top-to-bottom:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;boot.py          → create /data/cache on import
main.py          → entry point, WiFi, fetch loop, build state dict
home.py          → home screen layout (draw only)
chrome.py        → header + footer
wifi.py          → STA connection
weather.py       → Open-Meteo current conditions
wmo.py           → WMO weather code labels
http_client.py   → HTTP client (not network.py — clashes with built-in)
config.py        → load config.json
cache.py         → offline weather cache
clock.py         → NTP sync + date/time labels
battery.py       → ADC battery level
weather_icons.py → WMO weather bitmaps
display.py       → SPI setup + drawing facade
st7305.py        → panel driver (the hard part)
pins.py          → board pin map
text_sizes.py    → font size tokens
sync_device.py   → host-side deploy script (not copied to the board)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The design rules are simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;main.py&lt;/code&gt; owns the loop&lt;/strong&gt; - connect WiFi, fetch weather, when to redraw.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;home.py&lt;/code&gt; only draws&lt;/strong&gt; - it receives a plain &lt;code&gt;dict&lt;/code&gt; and never touches SPI or HTTP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;display.py&lt;/code&gt; hides hardware&lt;/strong&gt; - pages call &lt;code&gt;disp.text()&lt;/code&gt;, not &lt;code&gt;spi.write()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;weather.py&lt;/code&gt; / &lt;code&gt;wifi.py&lt;/code&gt; fetch&lt;/strong&gt; - network code never imports drawing modules.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Data flows in one direction: &lt;strong&gt;config → fetch → &lt;code&gt;build_state()&lt;/code&gt; → &lt;code&gt;render_home()&lt;/code&gt; → &lt;code&gt;refresh()&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The home page layout
&lt;/h3&gt;

&lt;p&gt;The home screen answers one question: &lt;em&gt;what's it like outside right now?&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────┐
│  Header: title · time · wifi · battery  │
├─────────────────────────────────────────┤
│                                         │
│         Home page content               │
│      (icon + location + readings)       │
│                                         │
├─────────────────────────────────────────┤
│  Footer: page · date · last updated     │
└─────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Layout is a &lt;strong&gt;two-column block&lt;/strong&gt; centered in the content area:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Left&lt;/th&gt;
&lt;th&gt;Right&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Weather icon (32×32, scaled 4×)&lt;/td&gt;
&lt;td&gt;Location name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;Large temperature&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;Humidity %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;Condition label&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;Feels-like (optional)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;UV index (optional)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;home.py&lt;/code&gt; computes the vertical stack height from whichever fields are present, centers the block between header and footer, and truncates long location names with &lt;code&gt;...&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Weather icons use &lt;a href="https://open-meteo.com/en/docs" rel="noopener noreferrer"&gt;WMO codes&lt;/a&gt; from the API. Temperature is drawn as digits, a small filled circle for the degree sign, then &lt;code&gt;C&lt;/code&gt;. The built-in 8×8 font has no ° glyph. Whole numbers look like &lt;code&gt;23&lt;/code&gt;; one decimal otherwise (&lt;code&gt;22.5&lt;/code&gt;). Missing data renders as &lt;code&gt;--&lt;/code&gt;.&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_home&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;disp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;disp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fill&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="nf"&gt;draw_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;disp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Home&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&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;time&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;state&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;battery&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;state&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;wifi&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="n"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;disp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;content_area&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c1"&gt;# ... layout math: icon_x, right_x, y ...
&lt;/span&gt;    &lt;span class="nf"&gt;draw_weather_icon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;disp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&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;weather_code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;icon_x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;icon_y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;_col_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;disp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&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;location&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;right_x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;right_w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;disp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SIZE_MEDIUM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;_col_temp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;disp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&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;temperature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;right_x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;right_w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;disp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SIZE_XL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# ... humidity, condition, feels-like, UV ...
&lt;/span&gt;
    &lt;span class="nf"&gt;draw_footer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;disp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&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;page_index&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;state&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;page_count&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;state&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_label&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;state&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;updated_label&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A dict in, pixels out. &lt;strong&gt;&lt;code&gt;chrome.py&lt;/code&gt;&lt;/strong&gt; draws the header (title, clock, WiFi/battery icons, divider) and footer (page counter, date, "Xm ago" label). &lt;strong&gt;&lt;code&gt;icons.py&lt;/code&gt;&lt;/strong&gt; supplies segment-style WiFi and battery glyphs plus &lt;code&gt;_blit_mono()&lt;/code&gt; for weather icons.&lt;/p&gt;




&lt;h2&gt;
  
  
  Saturday afternoon: live data
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Networking
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Config
&lt;/h4&gt;

&lt;p&gt;Copy &lt;code&gt;config.example.json&lt;/code&gt; to &lt;code&gt;config.json&lt;/code&gt; on the device:&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;"wifi"&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;"ssid"&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="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"password"&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="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;"location"&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;"latitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;52.0907&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"longitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;5.1214&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Utrecht"&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;"utc_offset_hours"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"intervals"&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;"weather_minutes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&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;Fill in your WiFi credentials before copying to the device.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;config.py&lt;/code&gt; merges this with defaults. Location name is display-only; lat/lon drive the API URL.&lt;/p&gt;

&lt;h4&gt;
  
  
  WiFi
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;wifi.py&lt;/code&gt; brings up STA mode (station mode, the board joins your home network as a client, not as an access point), connects with backoff on failure, and exposes &lt;code&gt;status()&lt;/code&gt; as &lt;code&gt;{"connected": bool, "level": 1–4}&lt;/code&gt; from RSSI, exactly what the header icons expect:&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;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_connected&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connected&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;level&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;level&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
    &lt;span class="n"&gt;rssi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wlan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rssi&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;rssi&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;55&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;4&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;rssi&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;65&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;3&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;rssi&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;75&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;2&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;level&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connected&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;level&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Weather
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;weather.py&lt;/code&gt; calls Open-Meteo with only the &lt;code&gt;current=&lt;/code&gt; fields the home page needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;temperature_2m, relative_humidity_2m, apparent_temperature, weather_code, uv_index
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The WMO &lt;code&gt;weather_code&lt;/code&gt; maps to a human label via &lt;code&gt;wmo.py&lt;/code&gt; and to a bitmap via &lt;code&gt;weather_icons.py&lt;/code&gt;. Successful fetches are saved to &lt;code&gt;/data/cache/weather_current.json&lt;/code&gt; so a failed refetch still shows the last good reading.&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;_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lon&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_loc&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;params&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;latitude={}&amp;amp;longitude={}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;amp;current=temperature_2m,relative_humidity_2m,apparent_temperature,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;weather_code,uv_index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;amp;timezone=auto&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lon&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;_BASE&lt;/span&gt; &lt;span class="o"&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="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;http_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_url&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;weather_current&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;http_client.py&lt;/code&gt; handles the HTTP side, retries, &lt;code&gt;gc.collect()&lt;/code&gt;, HTTPS→HTTP fallback, and always closes the response:&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;_attempt_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;timeout&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;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timeout&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;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;raise&lt;/span&gt; &lt;span class="nc"&gt;OSError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HTTP &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&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="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;return&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;finally&lt;/span&gt;&lt;span class="p"&gt;:&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;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# urequests leaks RAM if you skip this
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Main loop
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;main.py&lt;/code&gt; does the following on startup:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Load config&lt;/li&gt;
&lt;li&gt;Init display&lt;/li&gt;
&lt;li&gt;Connect WiFi&lt;/li&gt;
&lt;li&gt;NTP sync (if online)&lt;/li&gt;
&lt;li&gt;Fetch weather&lt;/li&gt;
&lt;li&gt;Enter loop: refetch every 10 minutes, redraw every minute (clock + battery), reconnect WiFi if dropped&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;build_state()&lt;/code&gt; assembles the dict &lt;code&gt;home.py&lt;/code&gt; expects, no global variables, no context object:&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_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wifi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weather&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;battery&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;weather&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;loc&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;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;location&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;bat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;battery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;location&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;loc&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;name&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;temperature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cur&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;temperature&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;humidity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cur&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;humidity&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;feels_like&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cur&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;feels_like&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;uv_index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cur&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;uv_index&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;weather_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;cur&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;weather_code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;condition&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cur&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;condition&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;Waiting&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;time&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format_time&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wifi&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;wifi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;battery&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;page_index&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;page_count&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;date_label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format_date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;updated_label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;weather&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updated_label&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 main loop only redraws when something changed, weather fetch, a new minute (&lt;code&gt;t[4]&lt;/code&gt; from &lt;code&gt;clock.now_local()&lt;/code&gt;), or reconnect. So you're not hammering SPI every second:&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;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&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;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now_local&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;wifi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_connected&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;wifi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&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;wifi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_connected&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;last_weather&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;interval&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;weather&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="n"&gt;dirty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
        &lt;span class="n"&gt;last_weather&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;last_minute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="c1"&gt;# t[4] = minute from clock.now_local()
&lt;/span&gt;        &lt;span class="n"&gt;last_minute&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;battery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;poll&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;dirty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;dirty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;render_home&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;disp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;build_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wifi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weather&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;battery&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;disp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;dirty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Supporting modules
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;weather_icons.py&lt;/code&gt;&lt;/strong&gt; - 32×32 bitmaps per WMO code. &lt;code&gt;draw_weather_icon(disp, code, x, y, scale=4)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;battery.py&lt;/code&gt;&lt;/strong&gt; - Reads GPIO4 ADC (3× divider per Waveshare docs) and maps voltage to a percent estimate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;clock.py&lt;/code&gt;&lt;/strong&gt; - &lt;code&gt;ntptime&lt;/code&gt; sync, &lt;code&gt;format_time()&lt;/code&gt; for the header, &lt;code&gt;format_date()&lt;/code&gt; like "Mon 6 Jul" for the footer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;cache.py&lt;/code&gt;&lt;/strong&gt; - Tiny JSON store with &lt;code&gt;fetched_at&lt;/code&gt; timestamps for offline display and the footer age label.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sunday: ship it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Flash &lt;a href="https://micropython.org/download/ESP32_GENERIC_S3/" rel="noopener noreferrer"&gt;MicroPython for ESP32-S3&lt;/a&gt; on the Waveshare board. I use &lt;a href="https://thonny.org/" rel="noopener noreferrer"&gt;Thonny&lt;/a&gt; (Run → Install MicroPython, pick the ESP32-S3 port) or &lt;code&gt;esptool.py&lt;/code&gt; from the command line.&lt;/li&gt;
&lt;li&gt;Install &lt;a href="https://docs.micropython.org/en/latest/reference/mpremote.html" rel="noopener noreferrer"&gt;mpremote&lt;/a&gt; on your PC: &lt;code&gt;python -m pip install mpremote&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Copy &lt;code&gt;config.example.json&lt;/code&gt; → &lt;code&gt;config.json&lt;/code&gt; on the device and set your WiFi + coordinates.&lt;/li&gt;
&lt;li&gt;Deploy from the repo folder:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python sync_device.py &lt;span class="nt"&gt;--port&lt;/span&gt; /dev/ttyUSB0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;COMx&lt;/code&gt; on Windows (with x being your port number). After a fresh MicroPython flash, run once with &lt;code&gt;--force&lt;/code&gt;. Add &lt;code&gt;--with-config&lt;/code&gt; to push the example config to &lt;code&gt;config.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sync_device.py&lt;/code&gt; uploads every root &lt;code&gt;.py&lt;/code&gt; except itself, then hard-resets the board. &lt;code&gt;boot.py&lt;/code&gt; creates &lt;code&gt;/data/cache&lt;/code&gt; before &lt;code&gt;main.py&lt;/code&gt; runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manual copy&lt;/strong&gt; works too, just copy every &lt;code&gt;.py&lt;/code&gt; except &lt;code&gt;sync_device.py&lt;/code&gt; to the device root with Thonny or mpremote.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Hardware reset&lt;/strong&gt; (not IDE soft reboot). I mean it, a soft reboot from Thonny often leaves SPI peripherals in a weird state.&lt;/li&gt;
&lt;li&gt;Serial console should show &lt;code&gt;WiFi OK&lt;/code&gt;, &lt;code&gt;NTP synced&lt;/code&gt;, &lt;code&gt;Weather updated&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Panel shows live conditions; clock ticks each minute; footer shows cache age.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If WiFi fails, you'll still see cached data from a previous session or placeholders (&lt;code&gt;Waiting&lt;/code&gt;, &lt;code&gt;No data&lt;/code&gt;) on first boot.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it looks like
&lt;/h3&gt;

&lt;p&gt;Mine, running in Utrecht:&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%2Fxhulvwu60a564gzmiozt.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%2Fxhulvwu60a564gzmiozt.png" alt="Live Open-Meteo data - clock, WiFi, battery in the header; cache age in the footer." width="800" height="599"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you get it running, share a photo of your screen in the comments showing weather where you are. I'd love to see these panels out in the wild.&lt;/p&gt;




&lt;h2&gt;
  
  
  What you built and what comes next
&lt;/h2&gt;

&lt;p&gt;You've got a flat MicroPython stack, one home screen, and live weather on the panel. All built from modules that don't need a rewrite when you add pages.&lt;/p&gt;

&lt;p&gt;This repo stops at one page, but the split is deliberate: &lt;code&gt;home.py&lt;/code&gt; and &lt;code&gt;chrome.py&lt;/code&gt; stay put when you add forecast pages, indoor sensors, or more screens. For the full multi-screen build, see &lt;strong&gt;&lt;a href="http://developer-service.blog/shop" rel="noopener noreferrer"&gt;Quiet Forecast&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Start with pixels. Then WiFi. Then one API call. Mount it when the panel shows your actual weather.&lt;/p&gt;

&lt;p&gt;Happy making.&lt;/p&gt;




&lt;p&gt;Follow me on Twitter: &lt;a href="https://twitter.com/DevAsService" rel="noopener noreferrer"&gt;https://twitter.com/DevAsService&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on Instagram: &lt;a href="https://www.instagram.com/devasservice/" rel="noopener noreferrer"&gt;https://www.instagram.com/devasservice/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on TikTok: &lt;a href="https://www.tiktok.com/@devasservice" rel="noopener noreferrer"&gt;https://www.tiktok.com/@devasservice&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on YouTube: &lt;a href="https://www.youtube.com/@DevAsService" rel="noopener noreferrer"&gt;https://www.youtube.com/@DevAsService&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>micropython</category>
      <category>esp32</category>
    </item>
    <item>
      <title>Tracking YouTube Video Watch Progress with Django</title>
      <dc:creator>Developer Service</dc:creator>
      <pubDate>Fri, 03 Jul 2026 10:57:53 +0000</pubDate>
      <link>https://dev.to/devasservice/tracking-youtube-video-watch-progress-with-django-11o7</link>
      <guid>https://dev.to/devasservice/tracking-youtube-video-watch-progress-with-django-11o7</guid>
      <description>&lt;p&gt;Your Django app serves a YouTube video. The page loads, the player appears, and your database records nothing. You have no idea whether the user pressed play, watched ten seconds, or finished the whole thing.&lt;/p&gt;

&lt;p&gt;That gap matters. For an e-learning course, a gated module, or any feature that requires users to actually consume content, "the page loaded" is the wrong signal. You need to know how much of the video they watched, not where the scrubber sits right now, but the furthest point they have ever reached.&lt;/p&gt;

&lt;p&gt;The YouTube IFrame Player API exposes playback position, duration, and state changes from JavaScript. Combined with a small Django backend, that is enough to track the maximum percentage each user has reached on each video and persist it per user. &lt;/p&gt;

&lt;p&gt;This article walks through a &lt;code&gt;videos&lt;/code&gt; app: one model, two views for tracking, one template with embedded JavaScript, and Django's built-in auth to tie saves to &lt;code&gt;request.user&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;A working demo is in the companion repository at &lt;a href="https://github.com/nunombispo/youtube-video-player-article" rel="noopener noreferrer"&gt;github.com/nunombispo/youtube-video-player-article&lt;/a&gt;, clone it to follow along.&lt;/p&gt;




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

&lt;p&gt;Five logical components make up the system. The diagram shows what each one owns and which boundaries it crosses:&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%2Fkwb8qzgxe6axfjagylmo.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%2Fkwb8qzgxe6axfjagylmo.png" alt="Architecture Diagram" width="800" height="503"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;video_detail&lt;/code&gt; template&lt;/strong&gt; - Django renders the page with the video ID from the URL and any stored progress for the current user. It owns the HTML structure: the player mount point, the progress bar, the CSRF token, and the script tags that load the IFrame API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Progress tracker (JavaScript)&lt;/strong&gt; - Runs in the browser after the template loads. It reads playback state from the YouTube player, maintains &lt;code&gt;maxPercentage&lt;/code&gt; locally, updates the progress bar, and POSTs to Django when the value increases. It never talks to the database directly; every persist goes through the view.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;YouTube IFrame Player&lt;/strong&gt; - An external embed controlled through the IFrame Player API. It supplies &lt;code&gt;getCurrentTime()&lt;/code&gt;, &lt;code&gt;getDuration()&lt;/code&gt;, and &lt;code&gt;onStateChange&lt;/code&gt; events. The tracker depends on it but does not own it, swap the video ID and the same tracker code works for any YouTube video.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Views&lt;/strong&gt; - &lt;code&gt;video_detail&lt;/code&gt; serves the page and queries existing progress on GET. &lt;code&gt;update_progress&lt;/code&gt; receives JSON POSTs and writes to the model. &lt;code&gt;@login_required&lt;/code&gt; on the update view ties each save to &lt;code&gt;request.user&lt;/code&gt; through Django auth. The view is the trust boundary: it validates input and enforces the rule that stored progress only moves forward.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;VideoProgress&lt;/code&gt; model&lt;/strong&gt; - One row per &lt;code&gt;(user, video_id)&lt;/code&gt; pair. Stores &lt;code&gt;max_percentage&lt;/code&gt; and &lt;code&gt;updated_at&lt;/code&gt;. The view reads it when rendering the page and writes it on each accepted POST.&lt;/p&gt;




&lt;h2&gt;
  
  
  Data model
&lt;/h2&gt;

&lt;p&gt;Progress tracking needs one persistent fact per user per video: the highest percentage ever reached. A single model holds that.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;unique_together&lt;/code&gt; on &lt;code&gt;user&lt;/code&gt; and &lt;code&gt;video_id&lt;/code&gt; enforces one row per pair. Without it, repeated saves could create duplicate rows for the same user watching the same video. &lt;code&gt;video_id&lt;/code&gt; is a &lt;code&gt;CharField&lt;/code&gt;, YouTube IDs are 11 characters, but 20 leaves room for other providers if you adapt the code later.&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;from&lt;/span&gt; &lt;span class="n"&gt;django.contrib.auth&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;get_user_model&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.db&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;

&lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_user_model&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;VideoProgress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ForeignKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;on_delete&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;video_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CharField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;max_percentage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PositiveIntegerField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auto_now&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&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;Meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;unique_together&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;user&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;video_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;max_percentage&lt;/code&gt; only ever increases, in JavaScript on the client and again in the view on the server. The double guard handles out-of-order requests: a slow POST from 40% must not overwrite a record already at 60%. &lt;code&gt;updated_at&lt;/code&gt; refreshes on every save via &lt;code&gt;auto_now=True&lt;/code&gt;, which gives you a last-activity timestamp without extra view code.&lt;/p&gt;

&lt;p&gt;Register the model in &lt;code&gt;admin.py&lt;/code&gt; to inspect records during development:&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;from&lt;/span&gt; &lt;span class="n"&gt;django.contrib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;admin&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;VideoProgress&lt;/span&gt;

&lt;span class="n"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;VideoProgress&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add &lt;code&gt;'videos'&lt;/code&gt; to &lt;code&gt;INSTALLED_APPS&lt;/code&gt; in &lt;code&gt;settings.py&lt;/code&gt;, then run &lt;code&gt;makemigrations&lt;/code&gt; and &lt;code&gt;migrate&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The model is persistence only. Next, connect it to a page the user actually loads.&lt;/p&gt;




&lt;h2&gt;
  
  
  Template
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;video_detail.html&lt;/code&gt; extends a shared &lt;code&gt;base.html&lt;/code&gt; that loads Bootstrap 5 from a CDN. Layout and styling are incidental to tracking, what matters is how the template passes data from Django into the player and the JavaScript tracker. &lt;/p&gt;

&lt;p&gt;The HTML snippets below show the tracking-relevant markup only; the &lt;a href="https://github.com/nunombispo/youtube-video-player-article" rel="noopener noreferrer"&gt;demo repository&lt;/a&gt; wraps them in Bootstrap cards and columns.&lt;/p&gt;

&lt;p&gt;The template expects two context variables: &lt;code&gt;video_id&lt;/code&gt; from the URL and &lt;code&gt;progress&lt;/code&gt; from the database. The &lt;code&gt;video_detail&lt;/code&gt; view supplies both, which we will cover in the Views section below.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Player mount and CSRF:&lt;/strong&gt; The player &lt;code&gt;div&lt;/code&gt; carries the video ID as a data attribute. Django fills &lt;code&gt;{{ video_id }}&lt;/code&gt; from the URL; JavaScript reads &lt;code&gt;dataset.videoId&lt;/code&gt; without mixing template tags into the script logic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;{% csrf_token %}
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"ratio ratio-16x9 bg-dark"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"player"&lt;/span&gt; &lt;span class="na"&gt;data-video-id=&lt;/span&gt;&lt;span class="s"&gt;"{{ video_id }}"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;{% csrf_token %}&lt;/code&gt; renders a hidden input and ensures the &lt;code&gt;csrftoken&lt;/code&gt; cookie is available. Django's &lt;code&gt;CsrfViewMiddleware&lt;/code&gt; expects that cookie value back in the &lt;code&gt;X-CSRFToken&lt;/code&gt; header on POST requests. Include the tag on any page that POSTs via &lt;code&gt;fetch()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Seeding client state:&lt;/strong&gt; When a user returns to a video they have partially watched, &lt;code&gt;maxPercentage&lt;/code&gt; must start at the stored value, not zero. Otherwise a replay followed by an early tab close would under-report progress. The repo seeds &lt;code&gt;maxPercentage&lt;/code&gt;, &lt;code&gt;videoId&lt;/code&gt;, &lt;code&gt;isAuthenticated&lt;/code&gt;, and &lt;code&gt;updateProgressUrl&lt;/code&gt; at the top of a single &lt;code&gt;{% block extra_js %}&lt;/code&gt; script, together with the IFrame API tag and the full tracker, see the JavaScript section below.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;isAuthenticated&lt;/code&gt; lets the tracker skip POSTs for anonymous users. &lt;code&gt;updateProgressUrl&lt;/code&gt; uses &lt;code&gt;{% url %}&lt;/code&gt; so the path stays correct if you change URL patterns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Progress bar:&lt;/strong&gt; The bar renders from the same context on first load. JavaScript updates width and label as playback advances, so the user sees progress move without refreshing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"progress"&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"height: 1.25rem;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"progress-bar bg-danger"&lt;/span&gt;
         &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"progress-bar"&lt;/span&gt;
         &lt;span class="na"&gt;role=&lt;/span&gt;&lt;span class="s"&gt;"progressbar"&lt;/span&gt;
         &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"width: {{ progress.max_percentage|default:0 }}%"&lt;/span&gt;
         &lt;span class="na"&gt;aria-valuenow=&lt;/span&gt;&lt;span class="s"&gt;"{{ progress.max_percentage|default:0 }}"&lt;/span&gt;
         &lt;span class="na"&gt;aria-valuemin=&lt;/span&gt;&lt;span class="s"&gt;"0"&lt;/span&gt;
         &lt;span class="na"&gt;aria-valuemax=&lt;/span&gt;&lt;span class="s"&gt;"100"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"badge"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"progress-label"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ progress.max_percentage|default:0 }}%&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Debug panel:&lt;/strong&gt; A sidebar table shows server-stored values alongside live client state: current position, player state, and the last POST response. Remove it in production; it saves time while wiring up the tracking logic and confirming saves land without a page refresh.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Remove in production --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;table&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"table table-sm table-striped mb-0"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;tbody&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;th&lt;/span&gt; &lt;span class="na"&gt;scope=&lt;/span&gt;&lt;span class="s"&gt;"row"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Video ID&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;td&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"debug-video-id"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ video_id }}&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;th&lt;/span&gt; &lt;span class="na"&gt;scope=&lt;/span&gt;&lt;span class="s"&gt;"row"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Stored max&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;td&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"debug-stored-max"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ progress.max_percentage|default:0 }}%&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;th&lt;/span&gt; &lt;span class="na"&gt;scope=&lt;/span&gt;&lt;span class="s"&gt;"row"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Updated at&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;td&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"debug-stored-updated"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{% if progress %}{{ progress.updated_at }}{% else %}—{% endif %}&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;th&lt;/span&gt; &lt;span class="na"&gt;scope=&lt;/span&gt;&lt;span class="s"&gt;"row"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Client max&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;td&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"debug-client-max"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ progress.max_percentage|default:0 }}%&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;th&lt;/span&gt; &lt;span class="na"&gt;scope=&lt;/span&gt;&lt;span class="s"&gt;"row"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Current&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;td&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"debug-current"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;—&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;th&lt;/span&gt; &lt;span class="na"&gt;scope=&lt;/span&gt;&lt;span class="s"&gt;"row"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;State&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;td&amp;gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"debug-state"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;—&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;th&lt;/span&gt; &lt;span class="na"&gt;scope=&lt;/span&gt;&lt;span class="s"&gt;"row"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Last save&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;td&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"debug-last-save"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;—&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/tbody&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/table&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;IFrame API:&lt;/strong&gt; Load the YouTube script after the player element exists, at the bottom of the template in a &lt;code&gt;{% block extra_js %}&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://www.youtube.com/iframe_api"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API loads asynchronously and calls &lt;code&gt;onYouTubeIframeAPIReady&lt;/code&gt; when ready. Placing the script after the &lt;code&gt;div&lt;/code&gt; and deferring initialization to that callback avoids &lt;code&gt;YT is not defined&lt;/code&gt; errors.&lt;/p&gt;




&lt;h2&gt;
  
  
  Views
&lt;/h2&gt;

&lt;p&gt;Three views cover the demo. &lt;code&gt;home&lt;/code&gt; renders a landing page with a link to a sample video. &lt;code&gt;video_detail&lt;/code&gt; and &lt;code&gt;update_progress&lt;/code&gt; do the tracking work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;video_detail&lt;/code&gt;&lt;/strong&gt; runs on GET. It looks up any existing &lt;code&gt;VideoProgress&lt;/code&gt; row for the authenticated user and passes it to the template along with the &lt;code&gt;video_id&lt;/code&gt; from the 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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.shortcuts&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;VideoProgress&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;video_detail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;video_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;progress&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_authenticated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;progress&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;VideoProgress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;video_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;video_id&lt;/span&gt;
        &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;videos/video_detail.html&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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;video_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;video_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;progress&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;progress&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;Anonymous users get &lt;code&gt;progress = None&lt;/code&gt;; the template seeds &lt;code&gt;maxPercentage&lt;/code&gt; to 0. They can still watch the video. Saves are skipped in JavaScript and blocked by &lt;code&gt;@login_required&lt;/code&gt; on the update view.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;update_progress&lt;/code&gt;&lt;/strong&gt; runs on POST. It parses JSON, validates the payload, and upserts with a monotonic guard.&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;json&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.contrib.auth.decorators&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;login_required&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.http&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;JsonResponse&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.views.decorators.http&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;require_POST&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;VideoProgress&lt;/span&gt;

&lt;span class="nd"&gt;@login_required&lt;/span&gt;
&lt;span class="nd"&gt;@require_POST&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;update_progress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;data&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;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&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;video_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&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;video_id&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;percentage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&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;percentage&lt;/span&gt;&lt;span class="sh"&gt;'&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="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;video_id&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;percentage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;error&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;invalid data&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;VideoProgress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_or_create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;video_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;video_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;defaults&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;max_percentage&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;percentage&lt;/span&gt;&lt;span class="p"&gt;},&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;created&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;percentage&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_percentage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_percentage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;percentage&lt;/span&gt;
        &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;status&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;ok&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;get_or_create&lt;/code&gt; inserts on first visit. The &lt;code&gt;if not created&lt;/code&gt; branch updates only when the incoming percentage exceeds what is already stored. The user comes from &lt;code&gt;request.user&lt;/code&gt;, the JSON payload carries &lt;code&gt;video_id&lt;/code&gt; and &lt;code&gt;percentage&lt;/code&gt; only.&lt;/p&gt;

&lt;p&gt;Wire the views in &lt;code&gt;videos/urls.py&lt;/code&gt;:&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;from&lt;/span&gt; &lt;span class="n"&gt;django.urls&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;.&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;views&lt;/span&gt;

&lt;span class="n"&gt;urlpatterns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;path&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;views&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;home&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;home&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&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;videos/&amp;lt;str:video_id&amp;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;views&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video_detail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;video_detail&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&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;progress/update/&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;views&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;update_progress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;update_progress&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Include that file from &lt;code&gt;config/urls.py&lt;/code&gt;:&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;from&lt;/span&gt; &lt;span class="n"&gt;django.contrib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;admin&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.urls&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;include&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;

&lt;span class="n"&gt;urlpatterns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&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;admin/&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&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;accounts/&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;django.contrib.auth.urls&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="nf"&gt;path&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="nf"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;videos.urls&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Auth:&lt;/strong&gt; &lt;code&gt;@login_required&lt;/code&gt; on &lt;code&gt;update_progress&lt;/code&gt; redirects anonymous POSTs to the login page. Wire Django's built-in auth views and point &lt;code&gt;LOGIN_URL&lt;/code&gt; at them:&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="c1"&gt;# settings.py
&lt;/span&gt;&lt;span class="n"&gt;LOGIN_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/accounts/login/&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="n"&gt;LOGIN_REDIRECT_URL&lt;/span&gt; &lt;span class="o"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create a user with &lt;code&gt;createsuperuser&lt;/code&gt; (or your existing signup flow) before testing saves. Register &lt;code&gt;VideoProgress&lt;/code&gt; in &lt;code&gt;admin.py&lt;/code&gt; to inspect records at &lt;code&gt;/admin/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Open the Network tab and pause the video. You should see &lt;code&gt;POST /progress/update/&lt;/code&gt; with status 200 and &lt;code&gt;{"status": "ok"}&lt;/code&gt;. A 403 means the CSRF token is missing, check &lt;code&gt;{% csrf_token %}&lt;/code&gt; is on the page and &lt;code&gt;X-CSRFToken&lt;/code&gt; is in the request headers. A 302 means &lt;code&gt;@login_required&lt;/code&gt; redirected because the user is not authenticated.&lt;/p&gt;




&lt;h2&gt;
  
  
  JavaScript
&lt;/h2&gt;

&lt;p&gt;The tracker script lives in &lt;code&gt;video_detail.html&lt;/code&gt; inside &lt;code&gt;{% block extra_js %}&lt;/code&gt;, after the IFrame API script tag. It has four jobs: initialize the player, compute watch percentage, maintain the running maximum, and POST updates to Django.&lt;/p&gt;

&lt;p&gt;Declare template-sourced variables and throttle state first, &lt;code&gt;sendProgressToDjango()&lt;/code&gt; and &lt;code&gt;maybeSaveProgress()&lt;/code&gt; depend on them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;maxPercentage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="nx"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;max_percentage&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="na"&gt;default&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="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;videoId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;player&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;isAuthenticated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;is_authenticated&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="na"&gt;yesno&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;true,false&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}};&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;updateProgressUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{% url "update_progress" %}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;lastSavedPercentage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;maxPercentage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;lastSaveAttempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;SAVE_INTERVAL_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;STATE_NAMES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;UNSTARTED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ENDED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PLAYING&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PAUSED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BUFFERING&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;5&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CUED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;STATE_BADGES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text-bg-secondary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text-bg-success&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text-bg-danger&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text-bg-warning&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text-bg-info&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;5&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text-bg-secondary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;UI helpers:&lt;/strong&gt; &lt;code&gt;updateProgressBar()&lt;/code&gt; syncs the Bootstrap bar with &lt;code&gt;maxPercentage&lt;/code&gt;. &lt;code&gt;updateDebugDisplay()&lt;/code&gt; refreshes the debug panel if you kept it, skip both functions if you removed the panel.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateProgressBar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;percentage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;bar&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;progress-bar&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;progress-label&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;percentage&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aria-valuenow&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;percentage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;percentage&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateDebugDisplay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stateCode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;currentPct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;player&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getDuration&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;getWatchPercentage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;player&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getDuration&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDuration&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;currentTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;player&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getCurrentTime&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getCurrentTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;debug-client-max&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;maxPercentage&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentPct&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;debug-current&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
            &lt;span class="nx"&gt;currentPct&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;% (&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;s / &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;s)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stateCode&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;stateEl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;debug-state&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;STATE_NAMES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stateCode&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;stateCode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;stateEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;stateEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;badge debug-value &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;STATE_BADGES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stateCode&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text-bg-light text-dark&lt;/span&gt;&lt;span class="dl"&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;&lt;strong&gt;Player initialization:&lt;/strong&gt; YouTube's IFrame API calls &lt;code&gt;onYouTubeIframeAPIReady&lt;/code&gt; when the script finishes loading. Create the player therem calling &lt;code&gt;new YT.Player()&lt;/code&gt; before this fires raises an error.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onYouTubeIframeAPIReady&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;player&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;YT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Player&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;player&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;videoId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;videoId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;playerVars&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;autoplay&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="na"&gt;controls&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="na"&gt;rel&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="na"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;onStateChange&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;onPlayerStateChange&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;&lt;code&gt;autoplay: 0&lt;/code&gt; leaves playback control with the user. &lt;code&gt;rel: 0&lt;/code&gt; stops YouTube from suggesting unrelated videos when playback ends.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch percentage:&lt;/strong&gt; Divide current time by duration and round. Guard against &lt;code&gt;duration === 0&lt;/code&gt; while the player is still loading, without the guard you get &lt;code&gt;NaN&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getWatchPercentage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDuration&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getCurrentTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&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;&lt;strong&gt;Maximum tracking:&lt;/strong&gt; Store the highest value reached, not the current scrubber position. If a user watches to 80%, seeks back to 20%, and pauses, the stored value stays 80%.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;recordProgress&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;pct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getWatchPercentage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pct&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;maxPercentage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;maxPercentage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nf"&gt;updateProgressBar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;maxPercentage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;maybeSaveProgress&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;updateDebugDisplay&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;On &lt;code&gt;ENDED&lt;/code&gt;, set &lt;code&gt;maxPercentage&lt;/code&gt; to 100 directly. At the end of a video, &lt;code&gt;getCurrentTime()&lt;/code&gt; sometimes returns a value slightly below &lt;code&gt;getDuration()&lt;/code&gt; due to floating-point timing. Computing the percentage would give 99 instead of 100.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Saving to Django:&lt;/strong&gt; Read the CSRF cookie and POST JSON to the URL the template provided. On a successful response, update &lt;code&gt;lastSavedPercentage&lt;/code&gt; so the throttle in &lt;code&gt;maybeSaveProgress()&lt;/code&gt; does not re-send the same value, and refresh the debug panel's stored fields and last-save status.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getCsrfToken&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;cookies&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookie&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&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="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;cookie&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cookie&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;csrftoken=&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;cookie&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;substring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;csrftoken=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendProgressToDjango&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;percentage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isAuthenticated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;debug-last-save&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;skipped (not logged in)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;updateProgressUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-CSRFToken&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;getCsrfToken&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="na"&gt;video_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;videoId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;percentage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;percentage&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&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="nx"&gt;response&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="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;debug-last-save&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
                &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; (&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;percentage&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;%)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nx"&gt;lastSavedPercentage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;percentage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;debug-stored-max&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;percentage&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;debug-stored-updated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLocaleString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
                &lt;span class="nf"&gt;updateProgressBar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;percentage&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="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;debug-last-save&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error: &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&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="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;lastSaveAttempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&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;&lt;strong&gt;When to save:&lt;/strong&gt; Three triggers cover normal watching, explicit stops, and tab closes.&lt;/p&gt;

&lt;p&gt;While the video plays, a one-second interval calls &lt;code&gt;recordProgress()&lt;/code&gt;. When &lt;code&gt;maxPercentage&lt;/code&gt; increases, &lt;code&gt;maybeSaveProgress()&lt;/code&gt; POSTs at most every five seconds, enough to persist progress during long viewing sessions without flooding the server.&lt;/p&gt;

&lt;p&gt;On pause or end, &lt;code&gt;maybeSaveProgress(true)&lt;/code&gt; bypasses the throttle and saves immediately if there is unsaved progress.&lt;/p&gt;

&lt;p&gt;On tab close, &lt;code&gt;beforeunload&lt;/code&gt; records the latest position and POSTs any value not yet saved. Treat that POST as best-effort, browsers often cancel in-flight &lt;code&gt;fetch&lt;/code&gt; requests while the page unloads. Pause events and the five-second playback throttle cover most sessions; tab-close is a safety net. For stricter close behavior, &lt;code&gt;navigator.sendBeacon()&lt;/code&gt; is an option, but it cannot send the &lt;code&gt;X-CSRFToken&lt;/code&gt; header, so you would need a separate CSRF strategy for that endpoint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;maybeSaveProgress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;force&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isAuthenticated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;maxPercentage&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;lastSavedPercentage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;force&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lastSaveAttempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;SAVE_INTERVAL_MS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;sendProgressToDjango&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;maxPercentage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onPlayerStateChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;updateDebugDisplay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;YT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PlayerState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PLAYING&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;debugInterval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;debugInterval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nf"&gt;recordProgress&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
                &lt;span class="nf"&gt;updateDebugDisplay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;YT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PlayerState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PLAYING&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;1000&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="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;debugInterval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;clearInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;debugInterval&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;debugInterval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;YT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PlayerState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PAUSED&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;recordProgress&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nf"&gt;maybeSaveProgress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;YT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PlayerState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ENDED&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;maxPercentage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nf"&gt;updateProgressBar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;maybeSaveProgress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;beforeunload&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;recordProgress&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;maxPercentage&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;lastSavedPercentage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;sendProgressToDjango&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;maxPercentage&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;&lt;strong&gt;Buffering edge case:&lt;/strong&gt; buffering (state 3) can produce a &lt;code&gt;PAUSED&lt;/code&gt; event followed by &lt;code&gt;PLAYING&lt;/code&gt; when it resolves. A &lt;code&gt;PAUSED&lt;/code&gt; event does not always mean the user pressed pause. If spurious saves become a problem, add a short debounce on &lt;code&gt;PAUSED&lt;/code&gt;, check that the player is still paused 200ms later before recording.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Replay behavior:&lt;/strong&gt; when a user replays a video, &lt;code&gt;getCurrentTime()&lt;/code&gt; resets to 0 but &lt;code&gt;maxPercentage&lt;/code&gt; keeps its previous value. If a user watched 90%, replays, and closes at 10%, you want 90% stored, not 10%. Reset &lt;code&gt;maxPercentage&lt;/code&gt; to 0 only if your product logic requires it.&lt;/p&gt;

&lt;p&gt;The debug panel updates &lt;code&gt;stored max&lt;/code&gt;, &lt;code&gt;client max&lt;/code&gt;, &lt;code&gt;current&lt;/code&gt;, &lt;code&gt;state&lt;/code&gt;, and &lt;code&gt;last save&lt;/code&gt; in place after each successful POST, no page refresh required.&lt;/p&gt;




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

&lt;p&gt;The gap at the start of this article, a video on the page and nothing in the database, is closed. The IFrame Player reports playback state. JavaScript maintains &lt;code&gt;maxPercentage&lt;/code&gt; across pause, seek, and replay. Django receives POSTs, enforces the monotonic rule on the server, and persists one row per user and video in &lt;code&gt;VideoProgress&lt;/code&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%2F0t1m0n85dqvg1rsbzai4.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%2F0t1m0n85dqvg1rsbzai4.png" alt="YouTube Progress Django Demo" width="800" height="424"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That row is the payoff. &lt;code&gt;max_percentage&lt;/code&gt; is queryable anywhere in your project: unlock the next module at 80%, mark a course complete at 100%, or feed analytics without re-embedding the player. The tracking logic stays in one template and one view; the model is small enough to drop into an existing app without restructuring your project.&lt;/p&gt;

&lt;p&gt;The companion repository at &lt;a href="https://github.com/nunombispo/youtube-video-player-article" rel="noopener noreferrer"&gt;github.com/nunombispo/youtube-video-player-article&lt;/a&gt; runs the full pipeline, clone it if you want a working baseline before adapting the snippets to your own templates and URL layout. &lt;/p&gt;

&lt;p&gt;Strip the debug panel before shipping, tune the save interval to match your traffic, and decide whether tab-close persistence needs &lt;code&gt;sendBeacon()&lt;/code&gt; with a separate CSRF strategy. &lt;/p&gt;

&lt;p&gt;Getting from a working demo to a production course platform is a different problem, deployment, observability, auth hardening. If you want a senior review of that path before launch, see &lt;a href="https://developer-service.blog/work-with-me/" rel="noopener noreferrer"&gt;developer-service.blog/work-with-me&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The hard part, knowing how far each user has actually watched, is done.&lt;/p&gt;




&lt;p&gt;Follow me on Twitter: &lt;a href="https://twitter.com/DevAsService" rel="noopener noreferrer"&gt;https://twitter.com/DevAsService&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on Instagram: &lt;a href="https://www.instagram.com/devasservice/" rel="noopener noreferrer"&gt;https://www.instagram.com/devasservice/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on TikTok: &lt;a href="https://www.tiktok.com/@devasservice" rel="noopener noreferrer"&gt;https://www.tiktok.com/@devasservice&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on YouTube: &lt;a href="https://www.youtube.com/@DevAsService" rel="noopener noreferrer"&gt;https://www.youtube.com/@DevAsService&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>django</category>
      <category>youtube</category>
    </item>
    <item>
      <title>Should Your App Adopt Passkeys?</title>
      <dc:creator>Developer Service</dc:creator>
      <pubDate>Thu, 25 Jun 2026 06:59:35 +0000</pubDate>
      <link>https://dev.to/devasservice/should-your-app-adopt-passkeys-2a9o</link>
      <guid>https://dev.to/devasservice/should-your-app-adopt-passkeys-2a9o</guid>
      <description>&lt;p&gt;Someone on your leadership team asked a reasonable question: should we adopt passkeys? &lt;/p&gt;

&lt;p&gt;You searched for answers and found implementation tutorials - WebAuthn server libraries, credential storage schemas, ceremony diagrams. They assume you've already decided. None of that helps you answer the question you were actually asked.&lt;/p&gt;

&lt;p&gt;This article is a decision guide. The question isn't how to implement passkey login. It's whether you should, when the timing makes sense, and for which users first. Implementation details matter eventually - but they don't belong at the front of the decision.&lt;/p&gt;

&lt;p&gt;You've seen Apple's demos and Google's Chrome nudges. Your security team may have sent a memo about phishing-resistant authentication. You know the term. What you don't have is a clear way to evaluate whether passkeys fit your product, your users, and your team's capacity to ship and support them.&lt;/p&gt;

&lt;p&gt;By the end of this article, you'll have scored your app against a readiness checklist, mapped show-stoppers that can block adoption, and drafted a one-page recommendation for leadership.&lt;/p&gt;




&lt;h2&gt;
  
  
  Plain Terms: Passkeys, Passwords, and MFA
&lt;/h2&gt;

&lt;p&gt;Before scoring your app, you and stakeholders need to mean the same thing when you say "passkey", "password", and "MFA". Vendor decks use these loosely. A PM might say "passkeys replace passwords" while security means "phishing-resistant credentials". Both can be true.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Passwords&lt;/strong&gt; are shared secrets the user types; your server checks a hash. They leak via breaches and phishing sites. Users forget them, reuse them, and call support.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MFA&lt;/strong&gt; adds a second factor - app push, SMS, hardware key, or biometric. It cuts credential-stuffing and many phishing attacks, but adds friction, lost-device tickets, and cross-platform complexity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Passkeys&lt;/strong&gt; are cryptographic key pairs on the user's device. The private key never leaves the device or synced passkey manager. Sign-in means unlocking with biometrics or a PIN; your server stores only the public key and verifies a signature. On web, the browser handles the ceremony (the sign-in handshake between browser, OS, and server) - the user sees a system prompt, not your form. On mobile, Face ID or fingerprint unlocks the credential.&lt;/p&gt;

&lt;p&gt;Your team needs WebAuthn support (the web standard passkeys build on). Password reset flows become less central; support handles different questions - "I got a new phone" replaces "I forgot my password", but isn't necessarily easier.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Passkeys and MFA:&lt;/strong&gt; Passkeys often &lt;em&gt;are&lt;/em&gt; MFA - something you have (the device key) plus something you are or know (biometric/PIN). For many consumer apps, a passkey beats password plus SMS. In regulated contexts you may still want step-up auth. The question isn't "passkeys or MFA" - it's which combination fits your threat model.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What Passkeys Fix (and Don't)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What improves:&lt;/strong&gt; Phishing and credential-stuffing. A fake login page can't harvest a passkey - the credential is bound to your app's origin, not a reusable secret. If your threat model includes phishing or password reuse, passkeys address real attacks that SMS MFA struggles to stop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What doesn't change:&lt;/strong&gt; Stolen session tokens, malware on the device, coerced unlocks, insider threats, and support social engineering. Passkeys shift the attack surface; they don't eliminate it.&lt;/p&gt;

&lt;p&gt;"More secure" isn't a shipping criteria. You can adopt passkeys and still fail on recovery, cross-device sync, browser coverage, or support capacity. A rollout that locks users out is its own security incident.&lt;/p&gt;

&lt;p&gt;When presenting upstream, lead with risk reduction, then gaps. A useful framing: &lt;em&gt;"Passkeys eliminate phishing and credential-stuffing for users who adopt them. They don't protect compromised sessions or untested recovery. We'd need X engineering weeks, Y support training, and a phased rollout starting with Z segment"&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That sentence gives leadership a decision, not a sermon. Your security colleagues will respect the nuance; your product colleagues need it to plan. Avoid claiming passkeys "solve authentication" - they solve specific authentication failures.&lt;/p&gt;

&lt;p&gt;Don't oversell to get approval - security enthusiasm has killed projects that hit recovery and SSO blockers nobody mentioned. Users adopt passkeys when login works smoothly across their devices, not because they're cryptographically sound.&lt;/p&gt;




&lt;h2&gt;
  
  
  UX Reality
&lt;/h2&gt;

&lt;p&gt;This is where most passkey decisions should start: what does the experience feel like for &lt;em&gt;your&lt;/em&gt; users, not Apple's demo audience?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Registration:&lt;/strong&gt; Creating a passkey happens at sign-up or when you prompt an existing user. The OS shows a system dialog - not your branded modal - asking for Face ID, fingerprint, or PIN. If they dismiss it, you need a fallback that doesn't punish them. Rollouts fail when the prompt appears before trust is established or without "skip for now". On mobile, registration feels seamless if users already unlock with biometrics. On desktop, it depends on platform passkey managers, QR handoff from a phone, or a plugged-in security key. Test on the device your users actually own - not just your MacBook.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sign-in:&lt;/strong&gt; Faster than typing a password - when it works. Web browsers offer passkey autofill or a separate path; users may pick which device holds the credential. Mobile delegates to the OS. Web users hit browser gaps and cross-device confusion; mobile users hit reinstalls and platform switches ("my passkey was in iCloud"). Neither platform guarantees the vendor-demo flow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-device sync:&lt;/strong&gt; Passkeys live in iCloud Keychain, Google Password Manager, 1Password, or a hardware key with no sync. Synced credentials roam across devices on the same account; device-bound ones stay on one machine. A passkey on iPhone may appear on iPad automatically but not on a work Windows laptop - and the user may not expect that. Roaming credentials (synced via platform cloud) behave differently from device-bound credentials scoped to a single authenticator. You don't implement sync; you understand what your users' devices and managers will and won't do, and set expectations accordingly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Friction that kills adoption:&lt;/strong&gt; new device without sync; lost phone with sync disabled; shared computers (family PC, library kiosk) where personal biometrics don't fit; enterprise users refusing to store work credentials in personal iCloud. Walk through these scenarios with your team before writing a launch date.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ask about your audience:&lt;/strong&gt; What percentage sign in mobile vs web? Multiple devices or one? Shared devices? Will they understand "use your passkey on your phone to sign in here"? Mobile-first audiences on recent iOS/Android get good UX today. Web-heavy, heterogeneous audiences need more fallbacks and slower adoption.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Reality-check:&lt;/strong&gt; Walk through registration and sign-in on your oldest supported device, then on a family member's phone without explaining the steps.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Readiness Checklist - Score Your App
&lt;/h2&gt;

&lt;p&gt;Turn everything above into a number you can defend in a meeting.&lt;/p&gt;

&lt;p&gt;Score each item &lt;strong&gt;0&lt;/strong&gt; (gap, no plan), &lt;strong&gt;1&lt;/strong&gt; (planned/untested), or &lt;strong&gt;2&lt;/strong&gt; (verified in staging or production). Weight make-or-break items &lt;strong&gt;2×&lt;/strong&gt; - mobile-heavy products weight device mix; web-first products weight browser coverage; B2B weights recovery and fallbacks. Example: raw score 11, but recovery (0) and browser coverage (1) are 2× for your web-first app — treat as yellow, not a low green.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Score yourself now; 10 minutes.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;0&lt;/th&gt;
&lt;th&gt;1&lt;/th&gt;
&lt;th&gt;2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Auth stack&lt;/strong&gt; — Backend registers/verifies WebAuthn/FIDO2 credentials&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Mobile integration&lt;/strong&gt; — App uses platform authenticator&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Browser coverage&lt;/strong&gt; — Passkeys on browsers covering ≥80% of sign-in traffic&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Device mix&lt;/strong&gt; — Users predominantly on passkey-capable devices&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Account model&lt;/strong&gt; — Multiple credentials per user (passkey + password)&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Recovery path&lt;/strong&gt; — Tested way to regain access without original passkey&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Fallback flows&lt;/strong&gt; — Users who skip passkey setup can still sign in&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Support readiness&lt;/strong&gt; — Team trained on passkey troubleshooting&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Pilot metrics&lt;/strong&gt; — Can measure registration, sign-in success, tickets by auth method&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Cross-device sync&lt;/strong&gt; — Tested across device combinations your users actually use&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Thresholds:&lt;/strong&gt; Green &lt;strong&gt;14–20&lt;/strong&gt; - pilot-ready with fallbacks and measurement. Yellow &lt;strong&gt;9–13&lt;/strong&gt; - close, but fix gaps (often recovery, support, cross-device). Red &lt;strong&gt;0–8&lt;/strong&gt; - engineering work needed, not a launch announcement.&lt;/p&gt;

&lt;p&gt;A red score points to three paths - not "never":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Phased approach:&lt;/strong&gt; Ship passkeys as opt-in for supported devices while keeping passwords. Your full user base may be yellow while a narrow segment is green.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix and retest:&lt;/strong&gt; Address the 0s - usually recovery and cross-device - then rescore. Many teams jump from red to green in one focused QA sprint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wait:&lt;/strong&gt; If most users are on unsupported browsers, waiting six to twelve months may cost less than shipping a broken experience.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One &lt;strong&gt;0&lt;/strong&gt; on recovery or cross-device sync can veto a green total.&lt;/p&gt;

&lt;h3&gt;
  
  
  Show-Stoppers
&lt;/h3&gt;

&lt;p&gt;A green score doesn't guarantee a safe launch. Any of these can override your math.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recovery&lt;/strong&gt; is the hardest unsolved problem. With passwords, "forgot password" sends a reset link. With passkeys, there's no secret to reset - the credential is device-bound. If the user loses every device holding their passkey, you need a backup path designed in advance: passwords during transition, verified magic links, a second passkey on another device, or platform recovery users may not realize is load-bearing for your app.&lt;/p&gt;

&lt;p&gt;None of these are elegant. Each has tradeoffs in security and UX. "Good enough" means a documented recovery flow tested with real users - not a diagram in a design doc. Shipping without tested recovery locks users out; that's worse for trust and support cost than delaying one sprint.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning:&lt;/strong&gt; Recovery gaps cause more damage than delayed launch.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Enterprise SSO&lt;/strong&gt; adds complexity when users sign in via Okta, Azure AD, or Google Workspace. Who creates and stores the passkey - your app or the IdP? B2B customers may require corporate-managed passkey managers, not personal iCloud. Passkeys fit cleanly when you own auth end-to-end; scope pilots away from enterprise edge cases until the happy path works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Legacy users&lt;/strong&gt; - email-only accounts, social login, phone auth - each need a migration path. Forced upgrades fail: users ignore prompts or churn if you cut off their existing method. Gradual migration works: prompt at login, offer passkey setup as optional, keep the old method until success or explicit decline. "Forever" dual auth is valid; "remove passwords next quarter" without recovery solved is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Support burden&lt;/strong&gt; rises with new ticket types: new phone, laptop-vs-phone confusion, accidental passkey creation, "what is a passkey?" Password-reset tickets may spike before they fall. Budget training and scripts before launch; tag tickets by auth method. A successful rollout shows password-reset volume declining; a failed one shows total auth tickets rising.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pilot, Present, Decide
&lt;/h2&gt;

&lt;p&gt;Show-stoppers mean don't expose your entire user base on day one. A pilot produces real data from a cohort small enough to support manually.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Good first cohorts&lt;/strong&gt; share three traits: passkey-capable devices, enough technical comfort to tolerate rough edges, and tolerance for contacting support. If 60% of sign-ins are from iPhone, an iOS-heavy cohort reflects reality. Internal employees are a common starting point - reachable, forgiving, and available for interviews when flows break. Don't pilot first with your highest-value or least-forgiving customers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Design choices:&lt;/strong&gt; Opt-in ("Add a passkey" in settings) is safest. Prompted-at-login (skippable) surfaces friction faster. Keep passwords alongside passkeys - almost always. Pick one primary question: registration completion or passkey sign-in replacing passwords.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure from day one:&lt;/strong&gt; registration rate, sign-in success by platform, support tickets vs baseline, drop-off by step. Set targets before launch - e.g., ≥70% registration among prompted users, &amp;lt;2% auth-ticket increase.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expand&lt;/strong&gt; when registration and sign-in success meet targets, support volume is flat or down, and recovery handled every test case - widen the cohort or move from opt-in to prompted default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pause&lt;/strong&gt; when metrics are flat but not failing. Fix friction, retest with the same cohort before adding users.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Roll back&lt;/strong&gt; when lockouts occur, tickets spike, or recovery fails in production. Disable the prompt, keep passkeys for users who already registered, fix, then resume.&lt;/p&gt;

&lt;p&gt;Two weeks with 500 internal users produces more actionable data than a six-month beta for 50,000 customers with no owner watching metrics.&lt;/p&gt;

&lt;h3&gt;
  
  
  One-page template for leadership
&lt;/h3&gt;

&lt;p&gt;Put the verdict in the first sentence. Caveats come after.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Recommendation:&lt;/strong&gt; [Pilot / Proceed / Defer] — [one sentence]&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Readiness score:&lt;/strong&gt; [X/20] — [Green / Yellow / Red]&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rationale:&lt;/strong&gt; [primary benefit] · [why now or not yet] · [pilot scope: who, how long]&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Risks and mitigations:&lt;/strong&gt; [risk] → [mitigation]&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timeline:&lt;/strong&gt; [build weeks] · [pilot duration] · [decision point]&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resources:&lt;/strong&gt; [engineering] · [support training] · [legal/privacy, if needed]&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Open questions:&lt;/strong&gt; [what the pilot must answer]&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Identify sign-offs early - engineering (WebAuthn, recovery, metrics), product (UX, cohort, comms), security (threat model, MFA policy), legal/privacy if auth changes affect terms. You don't need every sign-off before a pilot, but name them so nobody blocks you after you've announced a date.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proceed (green, pilot validated):&lt;/strong&gt; &lt;em&gt;"We recommend expanding passkey sign-in to [segment] starting [date]. Pilot: [X]% registration, [Y]% sign-in success, flat auth tickets. Password fallback remains. Recovery tested for [scenarios]."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pilot first:&lt;/strong&gt; &lt;em&gt;"We recommend a 4-week pilot with internal iOS users before broader rollout. Readiness: 11/20 (yellow). Gaps: recovery testing, support scripts. Success: ≥70% registration, flat auth tickets. Decision to expand by [date]."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not yet:&lt;/strong&gt; &lt;em&gt;"Defer until recovery is tested. Readiness: 7/20. Interim: strengthen MFA, monitor platform support. Revisit in 6 months."&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  When to Say "Not Yet"
&lt;/h2&gt;

&lt;p&gt;"Not yet" sounds like indecision. It can be the most responsible answer.&lt;/p&gt;

&lt;p&gt;Defer when a bad rollout costs more than waiting: red score with no path to yellow, recovery unsolved, unsupported device mix, unscoped SSO, or no support capacity. Deferral means interim steps plus a revisit date - not an open-ended "we'll look at it later".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Instead of passkeys now:&lt;/strong&gt; replace SMS with TOTP or push MFA; add WebAuthn as a second factor alongside passwords; offer magic-link sign-in to reduce password exposure; track passkey-capable traffic in analytics and spike WebAuthn in staging.&lt;/p&gt;

&lt;p&gt;Pick one or two interim steps and name them in your one-page recommendation. Leadership wants progress, not a blank "we're waiting".&lt;/p&gt;

&lt;p&gt;Rescore in six to twelve months. Browser support in your analytics, IdP capabilities, recovery patterns, and your own auth stack all shift faster than vendor keynotes suggest. A deferral with a revisit date is a decision; "we'll look at it later" is avoidance.&lt;/p&gt;




&lt;p&gt;You started with &lt;em&gt;should we adopt passkeys?&lt;/em&gt; You now have vocabulary, a scored checklist, show-stoppers, a pilot design, and a one-page template. Whether your answer is pilot, proceed, or not yet — defend it with evidence, not vendor slides.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Need help with the decision?&lt;/strong&gt; I work with engineering teams on architecture reviews, production readiness, and getting auth stacks right before they ship. If you're between yellow and green on the checklist and want a senior second opinion, &lt;a href="https://developer-service.blog/work-with-me/" rel="noopener noreferrer"&gt;work with me&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;Follow me on Twitter: &lt;a href="https://twitter.com/DevAsService" rel="noopener noreferrer"&gt;https://twitter.com/DevAsService&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on Instagram: &lt;a href="https://www.instagram.com/devasservice/" rel="noopener noreferrer"&gt;https://www.instagram.com/devasservice/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on TikTok: &lt;a href="https://www.tiktok.com/@devasservice" rel="noopener noreferrer"&gt;https://www.tiktok.com/@devasservice&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on YouTube: &lt;a href="https://www.youtube.com/@DevAsService" rel="noopener noreferrer"&gt;https://www.youtube.com/@DevAsService&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;Image by &lt;a href="https://pixabay.com/users/mohamed_hassan-5229782/?utm_source=link-attribution&amp;amp;utm_medium=referral&amp;amp;utm_campaign=image&amp;amp;utm_content=9302462" rel="noopener noreferrer"&gt;Mohamed Hassan&lt;/a&gt; from &lt;a href="https://pixabay.com//?utm_source=link-attribution&amp;amp;utm_medium=referral&amp;amp;utm_campaign=image&amp;amp;utm_content=9302462" rel="noopener noreferrer"&gt;Pixabay&lt;/a&gt;&lt;/p&gt;

</description>
      <category>passkeys</category>
      <category>security</category>
      <category>authentication</category>
    </item>
    <item>
      <title>PydanticAI vs LangChain - Choosing an Agent Framework for Production, Not Demos</title>
      <dc:creator>Developer Service</dc:creator>
      <pubDate>Mon, 22 Jun 2026 06:46:35 +0000</pubDate>
      <link>https://dev.to/devasservice/pydanticai-vs-langchain-choosing-an-agent-framework-for-production-not-demos-3fj</link>
      <guid>https://dev.to/devasservice/pydanticai-vs-langchain-choosing-an-agent-framework-for-production-not-demos-3fj</guid>
      <description>&lt;p&gt;In a recent audit, a team showed me an AI assistant they'd built on top of their company knowledge base. The demo had landed well: ask how to use a feature, and it walked through the exact pain point their support queue kept seeing. Leadership signed off.&lt;/p&gt;

&lt;p&gt;In production, the same agent told a user to open a menu option that didn't exist. Not a vague answer - a specific UI path, stated with confidence. Nobody caught it in testing. It surfaced when I audited the system, not when a user complained.&lt;/p&gt;

&lt;p&gt;The prototype passed testing because nobody was checking whether the answer matched the product. In production, that gap becomes a liability: the model invents UI paths, and your backend has no schema to reject them.&lt;/p&gt;

&lt;p&gt;When you're choosing an agent framework, popularity is the wrong scorecard. Pick the one that fails loudly in development and gracefully in production - or you'll find out in audit.&lt;/p&gt;




&lt;h2&gt;
  
  
  What "Production-Ready" Actually Requires
&lt;/h2&gt;

&lt;p&gt;Tutorial agents are built to impress in a fifteen-minute demo. Production agents run unattended, handle bad inputs, and ship answers your backend has to trust. The gap between those two goals is where most teams stumble - and it's rarely visible until something reaches a user.&lt;/p&gt;

&lt;p&gt;When I audit agent codebases, I evaluate five things the tutorials skip:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Structured, validated outputs:&lt;/strong&gt; Can your system reject an invented menu path before it becomes user-facing advice?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Dependency injection for testing:&lt;/strong&gt; Can you swap the knowledge base for a mock in CI without rewiring the agent?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Retry and error handling:&lt;/strong&gt; When the model returns malformed output, does the framework retry - or do you ship a parser exception?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Observability hooks:&lt;/strong&gt; Can you trace which document grounded a bad answer when support escalates?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Type-checker support:&lt;/strong&gt; Will static analysis catch a breaking API change before deploy, or after the agent silently misbehaves?&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to score your own system, the &lt;a href="https://developer-service.blog/work-with-me/#Production%20Readiness%20Audit:~:text=Production%20Readiness%20Audit%20%2D%20%E2%82%AC2%2C500" rel="noopener noreferrer"&gt;Production Readiness Audit&lt;/a&gt; covers the same five categories - deployment, observability, failure modes, and a prioritized remediation plan.&lt;/p&gt;




&lt;h2&gt;
  
  
  Side-by-Side: The Same Agent, Two Frameworks
&lt;/h2&gt;

&lt;p&gt;The first item on the rubric is structured, validated outputs. The clearest way to see the framework difference is to build the same agent twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The task:&lt;/strong&gt; answer natural-language questions about a CSV of sales data. The agent calls a tool to query the file, then returns a structured answer your API can pass downstream without a second parsing step.&lt;/p&gt;

&lt;h3&gt;
  
  
  LangChain
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain.agents&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;create_agent&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain.tools&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;

&lt;span class="nd"&gt;@tool&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;query_sales_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;region&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Return total revenue for a region in the sales CSV.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;region&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;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;revenue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;region&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;total&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anthropic:claude-sonnet-4-6&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;query_sales_csv&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&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;user&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;content&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;What was Q1 revenue in Europe?&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;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;  &lt;span class="c1"&gt;# str — you validate the shape yourself
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the pattern most tutorials teach. The tool works, the agent runs, the demo looks fine. But &lt;code&gt;answer&lt;/code&gt; is a string (or occasionally a dict, depending on the model). Nothing in this flow checks that the response contains a real region name, a numeric revenue, or the right currency. If the model formats the answer as prose instead of data, your code finds out in production - or in audit.&lt;/p&gt;

&lt;p&gt;LangChain does support a &lt;code&gt;response_format&lt;/code&gt; parameter with Pydantic models. It's opt-in, and most teams I audit haven't wired it up yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  PydanticAI
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic_ai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SalesAnswer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;total_revenue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;
    &lt;span class="n"&gt;currency&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;USD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anthropic:claude-sonnet-4-6&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SalesAnswer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@agent.tool_plain&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;query_sales_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;region&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;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Return total revenue for a region in the sales CSV.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;region&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;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;revenue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_sync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;What was Q1 revenue in Europe?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;  &lt;span class="c1"&gt;# SalesAnswer — validated before your code runs
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, validation isn't a step you add later - it's the contract. &lt;code&gt;output_type=SalesAnswer&lt;/code&gt; tells the agent what shape to return. If the model produces something that doesn't match - wrong field, missing revenue, invented region - PydanticAI raises before your application code touches it. You get a &lt;code&gt;SalesAnswer&lt;/code&gt; object your type checker understands, not a string you hope to parse.&lt;/p&gt;

&lt;p&gt;Same task, same tool, same model. The difference is what happens after the LLM responds: LangChain hands you text and trusts you'll validate it; PydanticAI hands you a typed object or fails immediately.&lt;/p&gt;




&lt;h2&gt;
  
  
  Dependency Injection &amp;amp; Testability
&lt;/h2&gt;

&lt;p&gt;Validated outputs tell you the shape is right. Dependency injection tells you the &lt;em&gt;data&lt;/em&gt; is right - and lets you prove it without calling a live API on every CI run.&lt;/p&gt;

&lt;p&gt;Agent tools don't operate in a vacuum. They read from databases, knowledge bases, and internal APIs. In production, those dependencies are real. In tests, they need to be fake - predictable, fast, and free. The question is whether your framework makes that swap explicit or forces you to hack around it.&lt;/p&gt;

&lt;h3&gt;
  
  
  PydanticAI: dependencies as a first-class parameter
&lt;/h3&gt;

&lt;p&gt;PydanticAI declares what an agent needs via &lt;code&gt;deps_type&lt;/code&gt;. Tools receive a &lt;code&gt;RunContext&lt;/code&gt; and pull dependencies from &lt;code&gt;ctx.deps&lt;/code&gt;. At run time, you pass the real implementation; in tests, you pass a fake.&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;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic_ai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RunContext&lt;/span&gt;

&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SalesDataSource&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;revenue_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;region&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;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;region&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;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;revenue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anthropic:claude-sonnet-4-6&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;deps_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SalesDataSource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;output_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SalesAnswer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@agent.tool&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;query_sales_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RunContext&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;SalesDataSource&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;region&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;float&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;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;revenue_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Production: agent.run_sync(prompt, deps=SalesDataSource())
# Test:       agent.run_sync(prompt, deps=FakeSalesData(revenue=1_250_000))
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The type checker enforces the contract. If a tool expects &lt;code&gt;SalesDataSource&lt;/code&gt; and you pass something else, mypy catches it before merge. Your test injects &lt;code&gt;FakeSalesData(revenue=1_250_000)&lt;/code&gt; and asserts the agent's structured output matches - no CSV file, no network, no API key in CI.&lt;/p&gt;

&lt;h3&gt;
  
  
  LangChain: it works, but the seams are yours to find
&lt;/h3&gt;

&lt;p&gt;LangChain agents can be tested, but the framework doesn't give you an injection point. The usual pattern is a module-level dependency the tool closes over, then &lt;code&gt;unittest.mock.patch&lt;/code&gt; in tests:&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;from&lt;/span&gt; &lt;span class="n"&gt;unittest.mock&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;patch&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain.agents&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;create_agent&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain.tools&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;

&lt;span class="n"&gt;data_source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SalesDataSource&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# module-level — no framework injection point
&lt;/span&gt;
&lt;span class="nd"&gt;@tool&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;query_sales_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;region&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Return total revenue for a region in the sales CSV.&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;region&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;data_source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;revenue_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anthropic:claude-sonnet-4-6&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;query_sales_csv&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="c1"&gt;# Test: patch the module where data_source lives
&lt;/span&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;myapp.sales_agent.data_source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;FakeSalesData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;revenue&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1_250_000&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&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;user&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;content&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;Q1 Europe?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}]})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here you're patching a string path - &lt;code&gt;"myapp.sales_agent.data_source"&lt;/code&gt; - that must match exactly where the module is imported. Rename the file, change the import structure, or run tests in parallel that share a patched global, and you get flakes or false greens.&lt;/p&gt;

&lt;p&gt;If you've fought flaky agent tests, you've lived this. The test doesn't fail because the agent logic is wrong; it fails because the test setup is fighting the framework's defaults.&lt;/p&gt;

&lt;p&gt;PydanticAI doesn't eliminate the need to write tests. It gives you a seam that was designed for swapping. That's the difference between "we test our agents in CI" and "we test our agents in CI reliably."&lt;/p&gt;




&lt;h2&gt;
  
  
  Error Handling and Retries in Practice
&lt;/h2&gt;

&lt;p&gt;When the model returns garbage, what happens next? In production, "garbage" isn't always obvious - it's a well-formed JSON object with &lt;code&gt;total_revenue: "approximately high"&lt;/code&gt; or a region name the CSV doesn't contain. The framework should catch that and recover, not pass it to your API.&lt;/p&gt;

&lt;h3&gt;
  
  
  PydanticAI: validation failures feed back to the model
&lt;/h3&gt;

&lt;p&gt;When &lt;code&gt;output_type&lt;/code&gt; is a Pydantic model, schema violations don't reach your application code. PydanticAI sends the validation error back to the model and retries:&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;from&lt;/span&gt; &lt;span class="n"&gt;pydantic_ai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ModelRetry&lt;/span&gt;

&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anthropic:claude-sonnet-4-6&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;output_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SalesAnswer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;retries&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="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@agent.output_validator&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;must_have_revenue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SalesAnswer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;SalesAnswer&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;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_revenue&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&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;ModelRetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Revenue must be positive. Call query_sales_csv and retry.&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="n"&gt;output&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;retries=3&lt;/code&gt; handles structural failures - wrong types, missing fields, malformed JSON. The &lt;code&gt;@output_validator&lt;/code&gt; handles business rules the schema can't express. Both paths raise &lt;code&gt;ModelRetry&lt;/code&gt;, which tells the agent to try again with the error message as context. If all retries exhaust, you get an explicit exception - not a silent bad record in your database.&lt;/p&gt;

&lt;h3&gt;
  
  
  LangChain: you assemble the loop
&lt;/h3&gt;

&lt;p&gt;LangChain can validate structured output via &lt;code&gt;response_format&lt;/code&gt;, but retry orchestration is still your code. Schema errors, business rules, retry limits, and message history - you wire it together:&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;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ValidationError&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain.agents&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;create_agent&lt;/span&gt;

&lt;span class="n"&gt;MAX_RETRIES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;

&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anthropic:claude-sonnet-4-6&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;query_sales_csv&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;response_format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SalesAnswer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;messages&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;role&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;user&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;content&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;What was Q1 revenue in Europe?&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;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_RETRIES&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;messages&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;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SalesAnswer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;structured_response&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;answer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_revenue&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&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;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Revenue must be positive&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ValidationError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;ValueError&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;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&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="p"&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;role&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;user&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;content&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="s"&gt;Validation failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;. Try again.&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="k"&gt;else&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;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Max retries exceeded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;LangChain also offers &lt;code&gt;ToolStrategy&lt;/code&gt; with a &lt;code&gt;handle_errors&lt;/code&gt; parameter for schema-level retries - closer to PydanticAI's defaults. But business-rule validation like &lt;code&gt;total_revenue &amp;gt; 0&lt;/code&gt; still lands in your loop. And teams using no &lt;code&gt;response_format&lt;/code&gt; pattern write even more of this by hand: parse JSON from message text, catch &lt;code&gt;OutputParserException&lt;/code&gt;, append errors, track attempts.&lt;/p&gt;

&lt;p&gt;The operational difference: PydanticAI treats "output didn't validate" as a normal agent loop event. LangChain treats it as an exception you handle - if you remembered to write the handler.&lt;/p&gt;




&lt;h2&gt;
  
  
  Testing Without Hitting the API
&lt;/h2&gt;

&lt;p&gt;In the previous sections, you saw how to test agent &lt;em&gt;logic&lt;/em&gt;. This section is about testing without paying for it. Every CI run that calls a real LLM costs money, adds latency, and flakes when the model paraphrases. Most teams know this; fewer build around it.&lt;/p&gt;

&lt;p&gt;PydanticAI ships test doubles for the model itself. &lt;code&gt;TestModel&lt;/code&gt; replaces the LLM with deterministic Python: it calls your tools, generates schema-valid output, and never hits the network. &lt;code&gt;FunctionModel&lt;/code&gt; goes further - you write the mock responses in plain Python when you need specific behavior.&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;from&lt;/span&gt; &lt;span class="n"&gt;pydantic_ai.models.test&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TestModel&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_sales_agent_returns_validated_output&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;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;override&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;TestModel&lt;/span&gt;&lt;span class="p"&gt;()):&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_sync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;What was Q1 revenue in Europe?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;deps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;FakeSalesData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;revenue&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1_250_000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SalesAnswer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_revenue&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;agent.override(model=TestModel())&lt;/code&gt; swaps the model at the agent boundary - the same boundary where you pass &lt;code&gt;deps=&lt;/code&gt; in previous sections. Your application code doesn't change; your test doesn't need an API key.&lt;/p&gt;

&lt;h3&gt;
  
  
  LangChain: script every model turn
&lt;/h3&gt;

&lt;p&gt;LangChain's equivalent is &lt;code&gt;GenericFakeChatModel&lt;/code&gt; - you pass an iterator of scripted &lt;code&gt;AIMessage&lt;/code&gt; responses, one per model invocation in the agent loop:&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;from&lt;/span&gt; &lt;span class="n"&gt;langchain_core.language_models.fake_chat_models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;GenericFakeChatModel&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain_core.messages&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AIMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ToolCall&lt;/span&gt;

&lt;span class="n"&gt;fake_model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GenericFakeChatModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;iter&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nc"&gt;AIMessage&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="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool_calls&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nc"&gt;ToolCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;query_sales_csv&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;region&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;Europe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;call_1&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="nc"&gt;AIMessage&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="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;region&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;Europe&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;total_revenue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: 1250000.0}&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="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;myapp.sales_agent.data_source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;FakeSalesData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;revenue&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1_250_000&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;test_agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;fake_model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;query_sales_csv&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;response_format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SalesAnswer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;test_agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&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;user&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;content&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;Q1 Europe?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}]})&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;SalesAnswer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;structured_response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="n"&gt;total_revenue&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here you're scripting each turn: first a tool call, then a JSON payload. Add a retry path or a second tool and you extend the iterator. Miss a turn and the test fails with an opaque &lt;code&gt;StopIteration&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For teams running agent tests on every PR, that's the single biggest cost-saving win I see when moving to PydanticAI: tests on every commit, no API bill, assertions on typed output.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where LangChain Still Wins
&lt;/h2&gt;

&lt;p&gt;This article argues for PydanticAI on production grounds. That case is weaker if you're not heading to production yet - and LangChain earns its popularity for good reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Integration breadth:&lt;/strong&gt; LangChain connects to more data sources, vector stores, and model providers out of the box. If you need a connector that PydanticAI doesn't ship yet - a niche CRM, an internal protobuf service, a legacy search index - LangChain's community has probably already built it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prototyping speed:&lt;/strong&gt; Pre-built chains, LangGraph templates, and copy-paste tutorials get a demo in front of stakeholders fast. For a two-week proof of concept where the goal is "show the CEO something that talks", that velocity matters more than typed outputs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ecosystem maturity:&lt;/strong&gt; More Stack Overflow answers, more LangSmith integrations, more hiring-market familiarity. If your team already knows LangChain and the project might not ship, switching frameworks adds cost with no payoff.&lt;/p&gt;

&lt;p&gt;None of this makes LangChain the wrong choice for production - teams ship reliable LangChain agents every day. But they do it by adding the validation, testing, and error-handling layers this article shows PydanticAI includes by default. If you're building those layers yourself anyway, the framework choice matters less.&lt;/p&gt;

&lt;p&gt;Choose LangChain when speed and integrations beat type safety. Choose PydanticAI when you're done demoing and need the agent to run without you in the room.&lt;/p&gt;




&lt;h2&gt;
  
  
  Decision Checklist
&lt;/h2&gt;

&lt;p&gt;Bring this to your next architecture review. Seven yes/no questions - answer for the project you're actually building, not the demo you already shipped:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Do you need typed outputs your backend can trust without re-validation?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Will this agent run unattended in production?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Do you need to unit-test agent logic in CI without API calls?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;When the model returns malformed output, should the framework retry automatically?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Do you need to trace which document or tool call produced a given answer?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Will static analysis catch breaking changes to your output schema before deploy?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Do you need a niche integration that only exists in LangChain's ecosystem today?&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Scoring:&lt;/strong&gt; Three or more yes on questions 1–6 → lean PydanticAI. Mostly no, or yes only on question 7 → LangChain is fine for now. Yes on 1–3 but no on 4–6 → you're heading to production but haven't built the hard parts yet. Either framework works, but budget time for the gaps this article mapped in sections 3–6.&lt;/p&gt;

&lt;p&gt;For a full assessment across your codebase - not just the framework choice - see the &lt;a href="https://developer-service.blog/work-with-me/#Production%20Readiness%20Audit:~:text=Production%20Readiness%20Audit%20%2D%20%E2%82%AC2%2C500" rel="noopener noreferrer"&gt;Production Readiness Audit&lt;/a&gt;. Same categories, applied to what you've actually shipped.&lt;/p&gt;




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

&lt;p&gt;If the checklist pointed you toward PydanticAI but you're already on LangChain, you don't need a rewrite. &lt;/p&gt;

&lt;p&gt;Your tools - the functions that query databases, search knowledge bases, and call internal APIs - are plain Python. Wrap them as PydanticAI &lt;code&gt;@agent.tool&lt;/code&gt; handlers and migrate one agent at a time. Run both frameworks side by side during the transition; retire LangChain paths as each agent passes the production tests you couldn't write before.&lt;/p&gt;

&lt;p&gt;The framework decision is one input. The harder question is whether your agent stack is ready for what happens after the demo - the invented menu options, the silent validation gaps, the CI runs that still hit the API.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://calendly.com/developer-service/work-with-me" rel="noopener noreferrer"&gt;Book a 20-minute intro call&lt;/a&gt; and tell me what you're building. Or jump straight to an &lt;a href="https://developer-service.blog/work-with-me/#:~:text=Architecture%20Call%20%2D%20%E2%82%AC300" rel="noopener noreferrer"&gt;Architecture Call&lt;/a&gt; or an &lt;a href="https://developer-service.blog/work-with-me/#:~:text=AI%20Code%20Health%20Check%20%2D%20%E2%82%AC1%2C500" rel="noopener noreferrer"&gt;AI Code Health Check&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;Follow me on Twitter: &lt;a href="https://twitter.com/DevAsService" rel="noopener noreferrer"&gt;https://twitter.com/DevAsService&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on Instagram: &lt;a href="https://www.instagram.com/devasservice/" rel="noopener noreferrer"&gt;https://www.instagram.com/devasservice/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on TikTok: &lt;a href="https://www.tiktok.com/@devasservice" rel="noopener noreferrer"&gt;https://www.tiktok.com/@devasservice&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on YouTube: &lt;a href="https://www.youtube.com/@DevAsService" rel="noopener noreferrer"&gt;https://www.youtube.com/@DevAsService&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>pydanticai</category>
      <category>langchain</category>
    </item>
    <item>
      <title>Why Architecture Reviews Need Contracts, Not Chat</title>
      <dc:creator>Developer Service</dc:creator>
      <pubDate>Fri, 12 Jun 2026 07:28:04 +0000</pubDate>
      <link>https://dev.to/devasservice/why-architecture-reviews-need-contracts-not-chat-3jjg</link>
      <guid>https://dev.to/devasservice/why-architecture-reviews-need-contracts-not-chat-3jjg</guid>
      <description>&lt;p&gt;Architecture reviews have a translation problem. &lt;/p&gt;

&lt;p&gt;Humans can leave a thread of “consider X” and “what about Y” and resolve the rest in a meeting. But if you want an LLM to participate in a workflow that resembles engineering - PRs, ADRs, ticketing, CI gates - fluent feedback isn’t enough. You need output that downstream systems (and humans) can reliably act on.&lt;/p&gt;

&lt;p&gt;Most “LLM architecture review” demos stop at persuasive prose. The result reads like an experienced engineer, but it isn’t shaped like an artifact: it’s hard to rank, route, deduplicate, or turn into work without a second manual pass.&lt;/p&gt;

&lt;p&gt;Multi-agent helps because architecture review is a bundle of lenses - security, scalability, operability, cost, data integrity, failure recovery - each with its own heuristics and thresholds. But the real differentiator isn’t “one agent vs. many”. It’s &lt;strong&gt;contracts&lt;/strong&gt;. With PydanticAI, you define the schema the system must emit and validate every response; Claude supplies the reasoning, but the contract forces it into a machine-actionable shape.&lt;/p&gt;

&lt;p&gt;This article shows how to build a multi-agent architecture reviewer that produces a &lt;strong&gt;structured review artifact&lt;/strong&gt;: normalized findings with severity, evidence, and recommendations, plus clarifying questions and explicit “needs human judgment” flags. Think less chatbot, more review report.&lt;/p&gt;

&lt;p&gt;The full runnable example lives in the &lt;a href="https://github.com/nunombispo/multi-agent-architecture-reviewer-article" rel="noopener noreferrer"&gt;companion repository&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In the next sections, we’ll define the minimal topology (planner → specialists → synthesizer), the shared contracts, and an end-to-end walkthrough from design doc to structured report.&lt;/p&gt;




&lt;h1&gt;
  
  
  What Multi-Agent Buys You (and What It Costs)
&lt;/h1&gt;

&lt;p&gt;Multi-agent systems are easy to overuse. If all you need is a single round of feedback - “scan this design doc for obvious risks” or “suggest alternatives for this storage layer” - a well-constructed prompt with a structured output schema can get you most of the value with a fraction of the complexity. The moment you add agents, you add orchestration, state, and failure handling; if you don’t get clear returns, you’ve just built a slower, more expensive version of a single call.&lt;/p&gt;

&lt;p&gt;So what does multi-agent &lt;em&gt;actually&lt;/em&gt; buy you for architecture review? Primarily: &lt;strong&gt;separation of concerns&lt;/strong&gt; and &lt;strong&gt;parallel perspectives&lt;/strong&gt;. A single “review this architecture” prompt tends to collapse into a handful of generic patterns: it repeats the same risks across categories, it misses domain-specific edge cases, and it blurs “ask a question” with “assert a fact”. Splitting the work into roles lets you put sharper instructions and narrower context windows in front of each agent, and it gives you a natural place to express uncertainty (“as the security reviewer, I need X to conclude Y”).&lt;/p&gt;

&lt;p&gt;Monolithic prompts also fail in predictable ways. They produce long outputs that are hard to rank; they mix high-confidence issues with speculative ones; they contradict themselves; and they often lack traceable evidence tied to the input. Those failures aren’t just aesthetic - they make downstream automation unreliable. If you can’t consistently tell what is a “P0” vs. a “P3”, or what is a requirement vs. a suggestion, you can’t turn the review into an engineering workflow.&lt;/p&gt;

&lt;p&gt;The costs are real. Fan-out (running multiple specialists) increases token spend and latency, and it introduces coordination overhead: you need to manage shared context, avoid duplication, and merge disagreements into a single report. Sequential pipelines can reduce duplication and keep context tighter, but they can amplify early mistakes (if the first step narrows scope incorrectly, everything downstream inherits that error). In practice, you’re trading a single model call for a small distributed system.&lt;/p&gt;

&lt;p&gt;For a lean architecture reviewer, a minimal topology works well:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;planner&lt;/strong&gt; reads the input and decides which lenses to apply (security, scalability, operability, data, cost), plus any clarifying questions.&lt;/li&gt;
&lt;li&gt;A small set of &lt;strong&gt;specialists&lt;/strong&gt; each run one lens and emit findings in a shared schema.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;synthesizer&lt;/strong&gt; merges the results: deduplicates, ranks, resolves contradictions (or preserves dissent explicitly), and produces the final structured review artifact.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The decision heuristic is simple: &lt;strong&gt;stay lean unless you need genuinely different perspectives&lt;/strong&gt;. If your review output is consistently repetitive, shallow, or internally inconsistent, specialist roles can help. If you’re already getting high-quality structured output from one call, don’t add agents - add better contracts, better evidence requirements, and better “unknowns” handling.&lt;/p&gt;




&lt;h1&gt;
  
  
  Contracts First: Pydantic Models as the Agent API
&lt;/h1&gt;

&lt;p&gt;If you only take one idea from this article, it should be this: &lt;strong&gt;the schema is the product&lt;/strong&gt;. The model is a reasoning engine, but your system’s reliability comes from the contract you force that reasoning to satisfy. Most agent demos skip this and then wonder why their outputs can’t be trusted in an engineering workflow.&lt;/p&gt;

&lt;p&gt;Architecture review is especially sensitive to this because it’s full of ambiguity. Some findings are hard requirements (“this violates a compliance constraint”), some are conditional (“if traffic can spike 10×, you need backpressure”), and some are simply questions (“what is your RTO/RPO?”). Without a contract that distinguishes these categories - and forces the model to attach evidence or explicitly mark unknowns - you end up with prose that sounds plausible but is operationally unusable.&lt;/p&gt;

&lt;p&gt;In Pydantic terms, you want to model the output as a small set of types that match how engineers actually consume reviews. A typical core might include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A top-level &lt;code&gt;ArchitectureReview&lt;/code&gt; artifact (metadata, overall risk, summary).&lt;/li&gt;
&lt;li&gt;A list of &lt;code&gt;Finding&lt;/code&gt; objects with fields like &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;severity&lt;/code&gt;, &lt;code&gt;category&lt;/code&gt;, and &lt;code&gt;recommendation&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;evidence&lt;/code&gt; or &lt;code&gt;references&lt;/code&gt; field that ties the claim to something in the input (or marks it as an inference).&lt;/li&gt;
&lt;li&gt;A separate list of &lt;code&gt;questions&lt;/code&gt; for missing information that blocks a confident conclusion.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The exact fields are less important than the discipline: &lt;strong&gt;every claim must land somewhere explicit&lt;/strong&gt;. Severity can’t be buried in adjectives. Recommendations can’t be scattered across paragraphs. Uncertainty can’t be implied; it needs a home in the schema.&lt;/p&gt;

&lt;p&gt;Contracts also apply to inputs. Each agent role should have a clear definition of what context it is allowed to assume and what it must treat as unknown. That’s the difference between a specialist saying “use mTLS” by default versus saying “if there are multi-tenant boundaries or untrusted networks, consider mTLS; otherwise justify why it’s unnecessary”. The more explicit you are about inputs (constraints, traffic assumptions, data classification, SLOs), the less your agents have to guess.&lt;/p&gt;

&lt;p&gt;Once you have contracts, you can make the system resilient with validation and repair loops. With PydanticAI-style structured outputs, you can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Validate every model response against the schema.&lt;/li&gt;
&lt;li&gt;Retry on validation failure with a targeted instruction (“output must include &lt;code&gt;evidence&lt;/code&gt; for each finding”).&lt;/li&gt;
&lt;li&gt;Apply a small “repair” step when the model is close but not quite compliant (missing a field, wrong enum value).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This isn’t about being pedantic. It’s what turns “LLM output” into “typed data” you can route downstream: create issues in a tracker, post a summarized comment to a PR, generate an ADR checklist, or feed a dashboard that tracks the recurring classes of architectural risk your org keeps rediscovering.&lt;/p&gt;

&lt;p&gt;Multi-agent orchestration only works well when everyone speaks the same language. The contract is that language.&lt;/p&gt;

&lt;p&gt;If you want to go deeper on modeling, validation, and the patterns that make Pydantic contracts reliable in production, see &lt;a href="https://leanpub.com/practical-pydantic" rel="noopener noreferrer"&gt;&lt;em&gt;Practical Pydantic&lt;/em&gt;&lt;/a&gt; - a hands-on guide to data validation in Python, from core concepts through real-world APIs and pipelines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code example: a minimal contract set
&lt;/h2&gt;

&lt;p&gt;The working example in &lt;a href="https://github.com/nunombispo/multi-agent-architecture-reviewer-article/blob/main/models.py" rel="noopener noreferrer"&gt;&lt;code&gt;models.py&lt;/code&gt;&lt;/a&gt; defines a small set of Pydantic models that act as the “agent API” for planning, specialist review, and synthesis:&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;from&lt;/span&gt; &lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Lens&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;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;security&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;security&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;scalability&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scalability&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;operability&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;operability&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;data_integrity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data_integrity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Severity&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;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;p0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;p1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;p2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;p3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Evidence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quote&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;observation&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;inference&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;observation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;detail&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="nc"&gt;Field&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Quote or observation tied to the provided input.&lt;/span&gt;&lt;span class="sh"&gt;"&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;Finding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&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="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;category&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="nc"&gt;Field&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Short category label (e.g. authz, backpressure).&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Severity&lt;/span&gt;
    &lt;span class="n"&gt;evidence&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;Evidence&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&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;recommendation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PlannerOutput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;lenses&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;Lens&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;scope_notes&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="sh"&gt;""&lt;/span&gt;
    &lt;span class="n"&gt;clarifying_questions&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;list&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;ArchitectureReview&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;overall_risk&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;low&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;medium&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;high&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;medium&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;findings&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;Finding&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&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;questions&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  Role Design: Planner, Specialists, Synthesizer
&lt;/h1&gt;

&lt;p&gt;The fastest way to ruin a multi-agent system is to give every agent the same vague job: “review the architecture”. You’ll pay for multiple calls and still get duplicated, generic output. Role design is where multi-agent becomes an engineering tool instead of a prompt trick.&lt;/p&gt;

&lt;p&gt;A useful rule is: &lt;strong&gt;each role must have a one-sentence job description&lt;/strong&gt; that is both necessary and non-overlapping. If two roles can produce the same kind of output, you haven’t created separation of concerns - you’ve created redundancy.&lt;/p&gt;

&lt;p&gt;For a lean architecture reviewer, three roles are enough:&lt;/p&gt;

&lt;h2&gt;
  
  
  Planner
&lt;/h2&gt;

&lt;p&gt;The planner’s job is to &lt;strong&gt;decide what review should happen&lt;/strong&gt; given the input and constraints. It does not emit a full review. It produces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which lenses to apply (security, scalability, operability, cost, data integrity, compliance, etc.).&lt;/li&gt;
&lt;li&gt;Any clarifying questions that block a confident review (“What’s the expected peak RPS?”, “What is the data classification?”).&lt;/li&gt;
&lt;li&gt;Optional scoping notes (“Focus on failure recovery and multi-region; ignore UI concerns.”).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This role is where you avoid wasted work. If the architecture is a batch pipeline with no public ingress, a deep web security pass is noise. If it’s a multi-tenant SaaS, ignoring tenant boundaries is negligence. The planner sets those priorities explicitly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Specialists
&lt;/h2&gt;

&lt;p&gt;Each specialist’s job is to &lt;strong&gt;run one lens and emit findings in the shared contract&lt;/strong&gt;. Specialists should not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Re-scope the review (“I think we should also do operability”).&lt;/li&gt;
&lt;li&gt;Invent missing context as if it were true.&lt;/li&gt;
&lt;li&gt;Produce long narrative prose that the synthesizer can’t merge.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They should be opinionated within their lens, but disciplined about uncertainty. A good specialist output contains high-signal findings &lt;em&gt;and&lt;/em&gt; clear questions when key context is missing. The contract is the forcing function: each finding needs a category, a severity, evidence, and a recommendation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Synthesizer
&lt;/h2&gt;

&lt;p&gt;The synthesizer’s job is to &lt;strong&gt;produce the final &lt;code&gt;ArchitectureReview&lt;/code&gt; artifact&lt;/strong&gt;. That means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deduplicate overlapping findings across specialists.&lt;/li&gt;
&lt;li&gt;Resolve contradictions when possible, or preserve dissent when it matters (“Security flags P0 unless X; Scalability says acceptable if Y”).&lt;/li&gt;
&lt;li&gt;Rank and prioritize based on severity and expected impact.&lt;/li&gt;
&lt;li&gt;Produce a concise summary that is consistent with the structured findings, not an independent “new” review.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The synthesizer is also where you enforce global policy: severity definitions, house style, and what counts as acceptable evidence. In other words, it turns a bag of lens-specific opinions into a single report that an engineering team can act on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prompt boundaries
&lt;/h2&gt;

&lt;p&gt;Prompt boundaries are not decorative; they prevent scope creep and hallucinated authority. Each role should have explicit “must not” constraints. Examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The planner must not emit findings.&lt;/li&gt;
&lt;li&gt;Specialists must not rewrite the contract or invent missing facts.&lt;/li&gt;
&lt;li&gt;The synthesizer must not add new findings that were not supported by specialist outputs or input evidence (unless explicitly marked as an inference with low confidence).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When roles are crisp, orchestration becomes straightforward: you know what inputs each step needs and what outputs it is allowed to produce. When roles are fuzzy, you’ll spend your time chasing inconsistencies and blame-shifting between agents.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code example: defining role agents
&lt;/h2&gt;

&lt;p&gt;In the working example (&lt;a href="https://github.com/nunombispo/multi-agent-architecture-reviewer-article/blob/main/reviewer.py" rel="noopener noreferrer"&gt;&lt;code&gt;reviewer.py&lt;/code&gt;&lt;/a&gt;), each role is an &lt;code&gt;Agent&lt;/code&gt; with an &lt;code&gt;output_type&lt;/code&gt; set to one of the contracts, and all roles share the same dependency type (&lt;code&gt;ReviewDeps&lt;/code&gt;) carrying the design doc.&lt;/p&gt;

&lt;p&gt;Passing &lt;code&gt;deps=ReviewDeps(design_doc=...)&lt;/code&gt; alone is not enough: PydanticAI does not automatically inject dependencies into the prompt. Use dynamic instructions to attach the document to every run:&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;from&lt;/span&gt; &lt;span class="n"&gt;__future__&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;annotations&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;from&lt;/span&gt; &lt;span class="n"&gt;dotenv&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;load_dotenv&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic_ai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RunContext&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ArchitectureReview&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Lens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PlannerOutput&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SpecialistOutput&lt;/span&gt;


&lt;span class="nf"&gt;load_dotenv&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;DEFAULT_MODEL&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;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MODEL&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;anthropic:claude-sonnet-4-6&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="ow"&gt;not&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;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&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;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ANTHROPIC_API_KEY is not set. Export it or add it to a .env file in the project root.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&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;ReviewDeps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;design_doc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;


&lt;span class="n"&gt;planner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;DEFAULT_MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;deps_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ReviewDeps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;output_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;PlannerOutput&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;instructions&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;You are an architecture review planner. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Given the design doc, choose which review lenses to run, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;write scoping notes, and list clarifying questions. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Prefer asking questions over guessing.&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="nd"&gt;@planner.instructions&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;planner_design_doc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RunContext&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ReviewDeps&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;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;Design doc to review:&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;design_doc&lt;/span&gt;&lt;span class="si"&gt;}&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;make_specialist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Lens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ReviewDeps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SpecialistOutput&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;specialist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;DEFAULT_MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;deps_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ReviewDeps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;output_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SpecialistOutput&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="o"&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="s"&gt;You are the &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;lens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; specialist for an architecture review.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Return findings using the output schema.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;- Every finding should include concrete evidence tied to the input.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;- If you lack evidence, ask a question instead of inventing facts.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;- Be concise; prioritize the highest-impact issues.&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="nd"&gt;@specialist.instructions&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;specialist_design_doc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RunContext&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ReviewDeps&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;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;Design doc to review:&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;design_doc&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="n"&gt;specialist&lt;/span&gt;


&lt;span class="n"&gt;synthesizer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;DEFAULT_MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;deps_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ReviewDeps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;output_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ArchitectureReview&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;instructions&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;You are the synthesizer for a multi-agent architecture review.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Merge specialist outputs into one ArchitectureReview:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;- Deduplicate overlapping findings.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;- Rank by severity and impact.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;- If specialists disagree, either resolve via evidence or preserve uncertainty.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;- Keep the summary consistent with the structured findings.&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="nd"&gt;@synthesizer.instructions&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;synthesizer_design_doc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RunContext&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ReviewDeps&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;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;Design doc that was reviewed:&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;design_doc&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="bp"&gt;...&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  Routing Topology: Fan-Out, Sequence, and When to Stop
&lt;/h1&gt;

&lt;p&gt;With roles defined, the next question is routing: in what order do you run them, what state do you pass, and when do you stop? “Agent frameworks” often treat routing as a generic problem (graphs, tool routers, memory stores). Architecture review is narrower. You want a topology that is &lt;strong&gt;predictable&lt;/strong&gt;, &lt;strong&gt;auditable&lt;/strong&gt;, and &lt;strong&gt;cheap enough to run often&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;There are three common patterns that fit this use case:&lt;/p&gt;

&lt;h2&gt;
  
  
  Fan-out then synthesize (standard)
&lt;/h2&gt;

&lt;p&gt;Planner → Specialists (parallel) → Synthesizer.&lt;/p&gt;

&lt;p&gt;This is usually the sweet spot. The planner scopes and selects lenses, specialists run independently in parallel, and the synthesizer merges. Parallelism gives you speed and reduces the chance that one lens anchors another. The cost is duplication and conflict, which you then handle in synthesis.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gated sequence (when context is expensive)
&lt;/h2&gt;

&lt;p&gt;Planner → Specialist A → Specialist B → … → Synthesizer.&lt;/p&gt;

&lt;p&gt;Sequential routing makes sense when later steps depend on structured state produced earlier (e.g., the planner extracts a component inventory; specialists review component-by-component). The risk is error propagation: a missed component early can cause systematic blind spots.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two-pass loop (only when you need it)
&lt;/h2&gt;

&lt;p&gt;Planner → Specialists → Synthesizer → (optional) targeted re-asks → Synthesizer.&lt;/p&gt;

&lt;p&gt;If you do loops, keep them narrow. The goal isn’t “let the agents think longer”. It’s to repair specific defects: missing evidence, unclear severity, or unresolved contradictions. Targeted re-asks are cheaper and more reliable than open-ended “review again”.&lt;/p&gt;

&lt;h2&gt;
  
  
  State passing: structured, not conversational
&lt;/h2&gt;

&lt;p&gt;The “chat history soup” failure mode is real: you pass the entire transcript to every agent and hope they find what they need. The result is inconsistent emphasis and increasing token waste. For architecture review, treat state as data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The raw input (design doc excerpt, constraints, assumptions).&lt;/li&gt;
&lt;li&gt;A structured planner output (selected lenses, clarifying questions, scope notes).&lt;/li&gt;
&lt;li&gt;A shared contract for specialist findings.&lt;/li&gt;
&lt;li&gt;The synthesizer’s merged artifact.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This keeps each step anchored to the same fields, and it makes runs auditable: you can see which agent produced which finding and why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stopping conditions
&lt;/h2&gt;

&lt;p&gt;In a lean system, stopping conditions should be boring and strict. Common rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Max rounds&lt;/strong&gt;: no unbounded loops; if you need a third pass, you likely need a better contract or better inputs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Empty findings&lt;/strong&gt;: if specialists return no issues, don’t “try harder” unless the planner flagged missing context.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low-confidence signals&lt;/strong&gt;: if evidence is missing, prefer explicit questions/unknowns over additional speculative rounds.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reference topology
&lt;/h2&gt;

&lt;p&gt;The reference topology we’ll use in the end-to-end walkthrough is:&lt;/p&gt;

&lt;p&gt;Planner → (Security, Scalability, Operability, Data/Integrity) specialists in parallel → Synthesizer.&lt;/p&gt;

&lt;p&gt;If you add anything, add it reluctantly - and only after you can name the specific failure mode it fixes.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I review Python and AI codebases for security gaps, production readiness, and long-term maintainability. If that's something your team needs,&amp;nbsp;&lt;a href="https://developer-service.blog/work-with-me/" rel="noopener noreferrer"&gt;let's talk.&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Code example: orchestration (planner → specialists → synthesizer)
&lt;/h2&gt;

&lt;p&gt;The same example wires the routing logic directly in Python, passing &lt;strong&gt;structured state&lt;/strong&gt; (planner output, specialist JSON) rather than a growing chat transcript:&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="bp"&gt;...&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_review&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;design_doc&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;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;PlannerOutput&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;SpecialistOutput&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;ArchitectureReview&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;deps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ReviewDeps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;design_doc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;design_doc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;planner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_sync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Plan the review lenses and questions for this design doc.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;deps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;

    &lt;span class="n"&gt;specialist_outputs&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;SpecialistOutput&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;lens&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lenses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;specialist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;make_specialist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;specialist_outputs&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="n"&gt;specialist&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_sync&lt;/span&gt;&lt;span class="p"&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;Review this architecture with your lens. &lt;/span&gt;&lt;span class="sh"&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;Scope notes: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scope_notes&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;(none)&lt;/span&gt;&lt;span class="sh"&gt;'&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="n"&gt;deps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;synthesis_input&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;Synthesize the final ArchitectureReview from:&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&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;Planner clarifying questions:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;- &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&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;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;clarifying_questions&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(none)&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="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;Specialist outputs:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_dump_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;indent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&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;o&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;specialist_outputs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;final&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;synthesizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_sync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;synthesis_input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;deps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;specialist_outputs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;final&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  End-to-End Example: One Review from Design Doc to Structured Output
&lt;/h1&gt;

&lt;p&gt;An architecture review only becomes real when you can run it on a concrete input and get a structured artifact out the other side. This section sketches a single “happy path” run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Walkthrough inputs
&lt;/h2&gt;

&lt;p&gt;At minimum, you want three kinds of input:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Architecture description&lt;/strong&gt;: the system diagram in prose - components, dependencies, data flows, and boundaries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Constraints&lt;/strong&gt;: what is non-negotiable (compliance, latency targets, cloud restrictions, tenancy model).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Known risks / focus areas&lt;/strong&gt;: what the team is already worried about (migration, multi-region, PII, cost).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The biggest determinant of review quality is whether these are explicit. If the input does not state assumptions, the reviewer will either guess (bad) or ask a lot of questions (good but slower). Your contract should reward “ask a question” rather than “invent a fact”.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reference implementation sketch
&lt;/h2&gt;

&lt;p&gt;Conceptually, you define three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Schemas&lt;/strong&gt; (Pydantic models) for the planner output, specialist findings, and the final review artifact.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agents&lt;/strong&gt; bound to those schemas (planner agent, specialist agents, synthesizer agent).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orchestration&lt;/strong&gt; that routes structured state between them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Claude is the model behind each agent; PydanticAI is the layer that forces responses to fit the schema and provides retries/repairs when they don’t.&lt;/p&gt;

&lt;p&gt;Clone the &lt;a href="https://github.com/nunombispo/multi-agent-architecture-reviewer-article" rel="noopener noreferrer"&gt;repository&lt;/a&gt;, set &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt;, and run &lt;code&gt;python run_review.py&lt;/code&gt; to reproduce the walkthrough below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Happy path routing
&lt;/h2&gt;

&lt;p&gt;The run looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Planner call&lt;/strong&gt;: read the input, select lenses (e.g., Security, Scalability, Operability, Data/Integrity), and emit clarifying questions if required.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Specialist fan-out&lt;/strong&gt;: run each lens with the same input plus planner scope. Each specialist emits a list of &lt;code&gt;Finding&lt;/code&gt; objects (and questions/unknowns if the contract supports them).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Synthesizer merge&lt;/strong&gt;: merge the lists into a final &lt;code&gt;ArchitectureReview&lt;/code&gt; artifact: dedupe, rank, and normalize severity.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you log inputs and outputs at each step, this pipeline is easy to debug: you can see whether the planner scoped incorrectly, whether a specialist missed evidence, or whether synthesis merged incorrectly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Example input and output
&lt;/h2&gt;

&lt;p&gt;The final artifact should be something you can paste into a PR comment &lt;em&gt;and&lt;/em&gt; parse as data. A good output has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A short summary (what’s good, what’s risky).&lt;/li&gt;
&lt;li&gt;Ranked findings with clear severities and categories.&lt;/li&gt;
&lt;li&gt;Evidence tied to the input (or explicit “inference” flags).&lt;/li&gt;
&lt;li&gt;Actionable recommendations (what to change, what to measure, what to decide).&lt;/li&gt;
&lt;li&gt;A small set of clarifying questions that genuinely block conclusions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://github.com/nunombispo/multi-agent-architecture-reviewer-article" rel="noopener noreferrer"&gt;companion repository&lt;/a&gt; includes a toy design doc at &lt;a href="https://github.com/nunombispo/multi-agent-architecture-reviewer-article/blob/main/sample_design.md" rel="noopener noreferrer"&gt;&lt;code&gt;sample_design.md&lt;/code&gt;&lt;/a&gt; - short on purpose, but enough for specialists to anchor findings to real statements:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Example design doc (toy)&lt;/span&gt;

We are building a multi-tenant SaaS API that ingests events from customer apps.

&lt;span class="gu"&gt;## Components&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Public REST API behind an API gateway.
&lt;span class="p"&gt;-&lt;/span&gt; Worker service that processes events asynchronously.
&lt;span class="p"&gt;-&lt;/span&gt; Postgres for tenant metadata and configuration.
&lt;span class="p"&gt;-&lt;/span&gt; S3 for raw event payload storage.
&lt;span class="p"&gt;-&lt;/span&gt; Redis for rate limiting and job deduplication.

&lt;span class="gu"&gt;## Constraints / assumptions&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Tenants are identified by an API key.
&lt;span class="p"&gt;-&lt;/span&gt; Peak: 50k events/sec across tenants, spikes up to 5×.
&lt;span class="p"&gt;-&lt;/span&gt; PII may be present in event payloads.
&lt;span class="p"&gt;-&lt;/span&gt; 99.9% availability target for ingestion endpoint.

&lt;span class="gu"&gt;## Known risks / focus&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; We previously had incidents from retry storms.
&lt;span class="p"&gt;-&lt;/span&gt; We need to support data deletion by tenant (GDPR-style).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running &lt;code&gt;python run_review.py&lt;/code&gt; against that file produces &lt;code&gt;out_review.json&lt;/code&gt;. Here is an excerpt of the structured review (truncated for readability):&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;"summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"This multi-tenant SaaS event-ingestion platform has a **high overall risk profile** driven by four converging concern areas: (1) an undefined API key lifecycle… (2) absent tenant isolation controls… (3) PII in S3 with no encryption-at-rest strategy… (4) a GDPR deletion requirement spanning Postgres, S3, and Redis with no coordination mechanism…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"overall_risk"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"high"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"findings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"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;"GDPR Deletion Lacks Cross-Store Atomicity, Completeness Guarantee, and Audit Trail"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"compliance"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"p0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"evidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"quote"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"detail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"'We need to support data deletion by tenant (GDPR-style)' is listed as a known risk but no deletion workflow, ordering, or rollback strategy is documented."&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"observation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"detail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Data is spread across three independent stores — Postgres, S3, and Redis — with no described coordination mechanism."&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"recommendation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Implement a saga/orchestration pattern for tenant deletion that tracks per-store deletion state…"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"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;"API Key Lifecycle Management Undefined — No Rotation, Revocation, or Scoping Controls"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"authn"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"p1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"evidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"quote"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"detail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Tenants are identified by an API key — no mention of key rotation, revocation, expiry, or scoping anywhere in the design."&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"recommendation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Implement a full API key lifecycle: scoped creation, server-side HMAC-SHA256 hashing, rotation policies, and immediate revocation propagation via Redis…"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"questions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"What queue technology sits between the API gateway and the worker (SQS, Kafka, RabbitMQ, etc.)?"&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="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;Notice how each finding carries explicit evidence kinds (&lt;code&gt;quote&lt;/code&gt;, &lt;code&gt;observation&lt;/code&gt;, &lt;code&gt;inference&lt;/code&gt;) and a ranked severity - exactly the shape your contracts enforce, and exactly what makes the output routable into a PR comment or issue tracker without a second translation pass.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What did that run cost?&lt;/strong&gt; On this toy doc, a full pipeline (planner + four specialists + synthesizer, using &lt;code&gt;claude-sonnet-4-6&lt;/code&gt;) came to about &lt;strong&gt;$0.45&lt;/strong&gt;. That’s reasonable for an occasional architecture review on a real design doc; it’s expensive if you run it on every small PR. Treat this as one data point - cost scales with document length, lens count, validation retries, and model choice - not a fixed price tag. It’s another reason to keep the topology lean and scope lenses deliberately.&lt;/p&gt;

&lt;p&gt;The point isn’t the exact field names; it’s that the artifact can be routed into real workflows without a human reformatting it.&lt;/p&gt;




&lt;h1&gt;
  
  
  Failure Modes: Hallucinations, Conflicts, and Unknowns
&lt;/h1&gt;

&lt;p&gt;Architecture review is a high-trust activity. When a human reviewer says “this will fail under load”, you can ask why, argue about assumptions, or request a benchmark plan. When a model says it, you get a different problem: the statement is often &lt;em&gt;well-phrased&lt;/em&gt; but its epistemic status is unclear. Is it anchored in the input? Is it an inference? Is it a generic warning? If you don’t design for that, you’ll end up with a reviewer that either hallucinates confidently or hedges uselessly.&lt;/p&gt;

&lt;p&gt;This section is a set of failure modes worth designing against up front.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unsupported claims: make evidence a first-class field
&lt;/h2&gt;

&lt;p&gt;The simplest guardrail is structural: require each finding to carry evidence. “Evidence” can be a quote, a reference to a section of the input, or a concrete observation about the described architecture. If the input does not contain enough evidence, the finding should not pretend otherwise - it should downgrade severity or convert into a clarifying question.&lt;/p&gt;

&lt;p&gt;This one constraint changes behavior. Models are much less likely to invent specifics when they must attach them to an evidence slot. And when they do invent, it becomes visible: the evidence field will be empty, vague, or obviously unrelated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Specialist disagreement: preserve dissent when it matters
&lt;/h2&gt;

&lt;p&gt;Parallel specialists will disagree. Sometimes that’s a bug (one misunderstood the architecture). Sometimes it’s the point (tradeoffs are real). Synthesis should not always force consensus. A useful pattern is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If the disagreement is resolvable by the input, resolve it and cite the evidence.&lt;/li&gt;
&lt;li&gt;If the disagreement is resolvable by a missing fact, emit a question and present the conditional conclusions (“If X, then P0; if not, then P2”).&lt;/li&gt;
&lt;li&gt;If it’s a genuine tradeoff, preserve dissent explicitly and explain the consequence of each choice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal is not to “sound decisive”. It’s to help engineers decide with clarity about what hinges on what.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unknowns: treat “needs human” as a valid outcome
&lt;/h2&gt;

&lt;p&gt;Most model failures under architecture review are failures of uncertainty handling. The model would rather guess than admit it doesn’t know. Your contract should give it a safe place to put uncertainty: &lt;code&gt;unknown&lt;/code&gt;, &lt;code&gt;assumption&lt;/code&gt;, or &lt;code&gt;needs_human&lt;/code&gt; fields that are treated as valid outputs, not errors.&lt;/p&gt;

&lt;p&gt;This is also where you differentiate between “missing input” and “non-determinism”. Missing input can be fixed by asking a question. Non-determinism might require a benchmark, a threat model, or a human policy decision. Your reviewer should surface that explicitly instead of burying it in hedged prose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guardrails without over-engineering
&lt;/h2&gt;

&lt;p&gt;You don’t need a full evaluation harness to be safer than the average demo. A few cheap guardrails go a long way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Schema constraints&lt;/strong&gt;: enums for severity/categories; required evidence fields; bounded list sizes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rubric checks&lt;/strong&gt;: simple consistency rules (“P0 findings must include a clear blast radius and an action”).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spot re-asks&lt;/strong&gt;: targeted second-pass prompts when specific fields are weak (“rewrite evidence”, “justify severity”, “convert speculative claims into questions”).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The point is to fix predictable defects deterministically, not to create an open-ended “think harder” loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to log (so you can debug)
&lt;/h2&gt;

&lt;p&gt;If you deploy this, you want logs that help you answer: “which step failed, and how”? At minimum:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The input digest (so you can correlate runs without storing sensitive docs verbatim).&lt;/li&gt;
&lt;li&gt;Planner output (selected lenses, questions, scoping decisions).&lt;/li&gt;
&lt;li&gt;Each specialist’s structured findings (including validation failures/retries).&lt;/li&gt;
&lt;li&gt;Synthesizer merge decisions (deduping and any conflict resolution notes).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With that, you can debug multi-agent runs like any other pipeline: identify the step that produced bad data, tighten the contract or prompt for that role, and move on.&lt;/p&gt;




&lt;h1&gt;
  
  
  Operational Heuristics: Prompt Pack and Debugging
&lt;/h1&gt;

&lt;p&gt;If you treat your architecture reviewer like a one-off prompt, it will behave like one. If you treat it like a component in an engineering system, it becomes maintainable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build a “review prompt pack”
&lt;/h2&gt;

&lt;p&gt;Instead of hand-editing prompts in code, keep a small prompt pack with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The role definitions (planner, each specialist, synthesizer).&lt;/li&gt;
&lt;li&gt;Your rubric snippets (what counts as severity P0/P1/P2, what categories you care about).&lt;/li&gt;
&lt;li&gt;One or two output examples that demonstrate the contract “done right”.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This does two things. First, it creates a shared artifact for the team - people can review and improve it like any other engineering asset. Second, it makes drift obvious: if the output starts violating the rubric, you can update the pack instead of chasing ad-hoc prompt edits scattered through the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version your contracts like an API
&lt;/h2&gt;

&lt;p&gt;Once downstream systems depend on your schema, it becomes an API. Treat it that way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Make breaking changes intentionally (renames, enum changes, required fields).&lt;/li&gt;
&lt;li&gt;Consider adding a &lt;code&gt;schema_version&lt;/code&gt; to the top-level review artifact.&lt;/li&gt;
&lt;li&gt;Keep migration logic simple: prefer additive changes early, and prune later once consumers catch up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most failures in production-like agent systems aren’t “the model got dumber”. They’re “the contract moved and the assumptions didn’t”.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debugging checklist: contract vs. reasoning vs. routing
&lt;/h2&gt;

&lt;p&gt;When something goes wrong, you want a fast way to localize the problem:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Contract failure&lt;/strong&gt;: validation errors, missing fields, wrong enum values. Fix with stricter schemas, clearer instructions, or repair prompts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reasoning failure&lt;/strong&gt;: the model followed the schema but produced low-quality findings. Fix with better rubric, better lens prompts, and better evidence requirements.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Routing failure&lt;/strong&gt;: the right work didn’t run (wrong lenses selected), or state was passed incorrectly. Fix the planner logic and the state model; don’t patch around it in specialist prompts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is why structured state passing matters: you can inspect each stage and see whether the pipeline is broken structurally or semantically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep it lean: don’t add features until you feel pain
&lt;/h2&gt;

&lt;p&gt;It’s tempting to add memory, retrieval (RAG), tool routers, and evaluation harnesses immediately. Most of that is premature for a reviewer that’s still proving it can produce a single reliable structured artifact.&lt;/p&gt;

&lt;p&gt;Add only what fixes a named problem:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Add memory when you have multi-step interactions that truly benefit from long-lived context.&lt;/li&gt;
&lt;li&gt;Add evaluation when you’re shipping frequent prompt/contract changes and need regression protection.&lt;/li&gt;
&lt;li&gt;Add retrieval when your reviewer needs access to external specs, policies, or service inventories that are too large to paste into the input.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Extend one lens at a time
&lt;/h2&gt;

&lt;p&gt;The clean way to extend this system is to add a specialist, not to bloat existing ones. If you want a “Compliance” lens, define:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A new role prompt for compliance.&lt;/li&gt;
&lt;li&gt;The same output contract as the other specialists.&lt;/li&gt;
&lt;li&gt;A planner rule for when to include that lens.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because the contract is stable and synthesis already knows how to merge, you get extensibility without rewriting orchestration.&lt;/p&gt;




&lt;h1&gt;
  
  
  Closing: From Demo to Engineering Workflow
&lt;/h1&gt;

&lt;p&gt;The pattern in this article is deliberately simple: &lt;strong&gt;contracts + roles + a boring topology&lt;/strong&gt;. That combination is what turns “LLM feedback” into something you can actually integrate into engineering work.&lt;/p&gt;

&lt;p&gt;Contracts are the differentiator. They force the reviewer to produce findings as data, not prose. Roles keep each agent honest: the planner scopes, specialists apply lenses, and the synthesizer merges into a single artifact. The topology stays lean so you can run it often and debug it when it misbehaves.&lt;/p&gt;

&lt;p&gt;The practical question is where to plug this in. Architecture review is not a single event; it happens at different points in a system’s lifecycle. A structured reviewer can support a few common workflows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Design reviews&lt;/strong&gt;: run it on a design doc draft to surface missing assumptions and obvious risks before a meeting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PRs for architectural changes&lt;/strong&gt;: attach the structured artifact as a PR comment, with a short summary plus ranked findings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ADRs&lt;/strong&gt;: use the questions and “needs human judgment” fields to drive what the ADR must explicitly decide.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The point is not to replace humans. It’s to make the review loop tighter and more consistent - and to ensure the output is shaped like something your team can act on.&lt;/p&gt;

&lt;p&gt;If you outgrow the lean version, the next steps are straightforward:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Add an evaluation harness with a small set of “golden” design docs and expected findings, so prompt/contract changes don’t regress silently.&lt;/li&gt;
&lt;li&gt;Add organization-specific retrieval (policies, SLO templates, service inventories) when you repeatedly see “unknown” due to missing institutional context.&lt;/li&gt;
&lt;li&gt;Expand lens coverage one specialist at a time, keeping the contract stable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The best call to action is also the simplest: &lt;strong&gt;ship the smallest reviewer that returns structured findings with evidence&lt;/strong&gt;. Run it on one real design doc. If the output is useful, you’ll know exactly what to improve next. If it isn’t, don’t add more agents - tighten the contract and the inputs until it becomes reliable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try the code:&lt;/strong&gt; &lt;a href="https://github.com/nunombispo/multi-agent-architecture-reviewer-article" rel="noopener noreferrer"&gt;github.com/nunombispo/multi-agent-architecture-reviewer-article&lt;/a&gt; - clone, point &lt;code&gt;run_review.py&lt;/code&gt; at your own design doc, and iterate on contracts and lenses from there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Want to sharpen your Pydantic skills?&lt;/strong&gt; This article leans on schemas as the backbone of agent reliability. For a full treatment of validation, serialization, and real-world Pydantic patterns, check out &lt;a href="https://leanpub.com/practical-pydantic" rel="noopener noreferrer"&gt;&lt;em&gt;Practical Pydantic: The Missing Guide to Data Validation in Python&lt;/em&gt;&lt;/a&gt; on Leanpub.&lt;/p&gt;




&lt;p&gt;Follow me on Twitter: &lt;a href="https://twitter.com/DevAsService" rel="noopener noreferrer"&gt;https://twitter.com/DevAsService&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on Instagram: &lt;a href="https://www.instagram.com/devasservice/" rel="noopener noreferrer"&gt;https://www.instagram.com/devasservice/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on TikTok: &lt;a href="https://www.tiktok.com/@devasservice" rel="noopener noreferrer"&gt;https://www.tiktok.com/@devasservice&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on YouTube: &lt;a href="https://www.youtube.com/@DevAsService" rel="noopener noreferrer"&gt;https://www.youtube.com/@DevAsService&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>claude</category>
      <category>architetcure</category>
    </item>
    <item>
      <title>uv for Faster Teams, Fewer Environment Fires</title>
      <dc:creator>Developer Service</dc:creator>
      <pubDate>Mon, 01 Jun 2026 06:42:54 +0000</pubDate>
      <link>https://dev.to/devasservice/uv-for-faster-teams-fewer-environment-fires-37of</link>
      <guid>https://dev.to/devasservice/uv-for-faster-teams-fewer-environment-fires-37of</guid>
      <description>&lt;p&gt;Python dependency management is not a developer problem. It is a team productivity problem that shows up as slow CI, painful on-boarding, and a different tool in every repository.&lt;/p&gt;

&lt;p&gt;If you run a Python shop with five to thirty engineers, a measurable slice of your payroll quietly funds work that never ships: waiting on dependency installs, re-explaining local setup to new hires, and arbitrating which package manager each team decided to use this quarter. None of it shows up on a road-map. All of it compounds.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.astral.sh/uv/" rel="noopener noreferrer"&gt;uv&lt;/a&gt; is a Python package and project manager that replaces pip, pip-tools, and Poetry with a single, faster workflow. &lt;/p&gt;

&lt;p&gt;This article is not a tutorial. It is a business case: what standardizing on uv costs, what it returns, and how to run a one-week pilot without stopping feature work. By the end, you will have a go/no-go framework and a minimal team policy you can paste into your engineering handbook today.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Hidden Tax
&lt;/h2&gt;

&lt;p&gt;Most of this work is invisible. There is no ticket for "waited on a slow install". There is no incident report for "new hire lost two days to a broken environment". It just disappears into the sprint - and the next sprint, and the one after that.&lt;/p&gt;

&lt;p&gt;Those costs are not developer preferences. They are line items that nobody is measuring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Slow CI:&lt;/strong&gt; On many teams, the dependency install step is the longest part of a pull-request build. Every push pays that cost again. If installs take 10 minutes and you merge 20 PRs a week, you are buying a part-time engineer whose job is watching progress bars.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Onboarding drag:&lt;/strong&gt; Picture a new hire on day one. Clone the repo, follow the README, hit a version mismatch, ask in Slack, get three different answers, open a doc that was true six months ago. By Thursday they might have a green test run. You have already spent senior time you could not spend on the road-map.&lt;/p&gt;

&lt;p&gt;One of the simplest ways to make on-boarding real is to make the “first green test run” path as simple as a single copy-paste:&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 git@github.com:your-org/your-repo.git
&lt;span class="nb"&gt;cd &lt;/span&gt;your-repo
uv &lt;span class="nb"&gt;sync
&lt;/span&gt;pytest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Tool sprawl:&lt;/strong&gt; One service pins with pip-tools. Another uses Poetry because a contractor set it up. A third has a Makefile that wraps both. Your CTO job is not to pick the prettiest CLI; it is to know whether your organization can reproduce a production-like environment on demand. When every repo invents its own workflow, you are paying coordination tax on top of everything else.&lt;/p&gt;

&lt;p&gt;None of this requires a dramatic failure. It is the steady leak that makes "we are too busy to change tools" sound reasonable, even while the leak itself burns the capacity you are protecting.&lt;/p&gt;

&lt;p&gt;The fix is not a weekend rewrite of every repository. It is a policy decision: one default way to create environments, install dependencies, and lock what CI runs. That is where uv enters the conversation - not as hype about Rust, but as a way to stop re-litigating the same afternoon in every channel.&lt;/p&gt;

&lt;p&gt;The next section is about what actually changes when pip is no longer the unnamed default on your team.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Changes When Pip Isn't the Default
&lt;/h2&gt;

&lt;p&gt;Your engineers will talk about uv in terms of speed. You should hear something else: one reproducible workflow across repositories.&lt;/p&gt;

&lt;p&gt;At a high level, uv is a single tool-chain for creating virtual environments, resolving dependencies, and installing locked versions. Faster resolves matter because they shorten CI and local setup. Reproducible environments matter because they reduce "works on my machine" and make audits easier. One tool-chain matters because you stop paying the meeting tax every time a repo picks a different package manager.&lt;/p&gt;

&lt;p&gt;That is the executive translation. You do not need to understand Rust or memorize flags to approve the direction. You need to know that the team can answer three questions the same way everywhere: how do we create an environment, how do we install what this commit expects, and how does CI prove we did?&lt;/p&gt;

&lt;p&gt;You will still hear enthusiasm about performance. Treat it as supporting evidence, not the decision criterion. A startup CTO standardizes on uv when the organization needs a default, not when a benchmark chart wins an argument.&lt;/p&gt;

&lt;p&gt;What stays the same is larger than what changes. Your application code is still Python. Your runtime version policy is still yours. Most CI pipelines keep their shape: checkout, install dependencies, run tests. You are usually swapping the install step and standardizing on a lock-file in git, not re-platforming the product.&lt;/p&gt;

&lt;p&gt;What changes is the argument surface. Teams stop debating pip versus Poetry versus pip-tools on every new service. Code review stops treating "how we install" as a local choice. On-boarding docs can say one thing. When production misbehaves, you have fewer variables.&lt;/p&gt;

&lt;p&gt;The practical policy looks boring on purpose: uv is the default for new Python work; existing repos adopt it when touched or during a time-boxed pilot; lock-files are required; CI uses a frozen install so what merged is what ran. For commands and platform-specific setup, the &lt;a href="https://docs.astral.sh/uv/" rel="noopener noreferrer"&gt;official uv documentation&lt;/a&gt; is the reference. Your handbook holds the rules.&lt;/p&gt;

&lt;p&gt;The next section turns to evidence: which metrics to capture before and after so you can tell whether the pilot earned its half-day of attention.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Productivity Case
&lt;/h2&gt;

&lt;p&gt;Treat uv like any other productivity change: measure before, run a small pilot, then decide with data. If you cannot point to a number, you will default to opinion. And in tooling debates, opinions are loud.&lt;/p&gt;

&lt;p&gt;Start with three metrics that map cleanly to cost:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;CI dependency time&lt;/strong&gt;: how long the install step takes on a cold cache (and how often caches miss). If installs are ten minutes and you run fifty builds a week, you are spending roughly eight engineer-hours a week just waiting for dependencies.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cold clone-to-test time&lt;/strong&gt;: on a clean machine, how long it takes to go from &lt;code&gt;git clone&lt;/code&gt; to a green test run. This is onboarding time, but it also shows up every time a laptop is replaced, a new service is pulled down, or someone needs to reproduce a bug quickly.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Environment support load&lt;/strong&gt;: count tickets or Slack threads tagged “env”, “pip”, “poetry”, “dependency”, “won’t install”. You do not need perfect accounting. A rough baseline is enough to see direction.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now set expectations like a CTO, not a benchmark blog post. In a 5–30 engineer shop, you are usually chasing a combination of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Faster installs&lt;/strong&gt; (minutes back per build)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Fewer broken local environments&lt;/strong&gt; (fewer interruptions)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Less variance between repos&lt;/strong&gt; (less cognitive load)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Set a go/no-go before you start:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Decision&lt;/strong&gt;: roll out to new repos if the pilot clears either threshold below&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;CI threshold&lt;/strong&gt;: saves ≥ 10 minutes per PR in the dependency step&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Time threshold&lt;/strong&gt;: recovers ≥ 10 engineer-hours/month in avoided environment work&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you get even 1–2 minutes back per build on the dependency step and cut a couple of “why won’t this install” interruptions per week, the ROI can be real. The pilot cost is also real, but bounded: a half-day to wire one repo end-to-end and document one workflow. Do the math in hours, not gut feel.&lt;/p&gt;

&lt;p&gt;Be honest about when this will not matter. If your Python footprint is small, your CI time is dominated by integration tests, or your dependency set rarely changes, switching tools will not move the needle. In those cases, keep your current setup and spend the time where it buys you more.&lt;/p&gt;

&lt;p&gt;If the numbers suggest this is worth it, the next question is execution: how to adopt without turning it into a migration project.&lt;/p&gt;




&lt;h2&gt;
  
  
  Adopt Without a Migration Project
&lt;/h2&gt;

&lt;p&gt;The easiest way to fail a tooling change is to turn it into a program. The fastest way to succeed is to treat it like an experiment with a tight box around it.&lt;/p&gt;

&lt;p&gt;If your first reaction is “we don’t have bandwidth,” take that seriously. But also notice what it implies: you are already paying the bandwidth tax, just in a less visible form. The goal of a uv rollout is not purity. It is a measurable reduction in time spent on installs, environment drift, and repo-by-repo debate.&lt;/p&gt;

&lt;p&gt;Run a one-repo, one-week pilot. Do not start with the cleanest service. Pick the repo that creates the most noise: the CI job everyone waits on, or the codebase that makes onboarding a Slack scavenger hunt. If uv can help there, it will help anywhere.&lt;/p&gt;

&lt;p&gt;Keep the steps boring:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Add or standardize &lt;code&gt;pyproject.toml&lt;/code&gt; and commit the lock-file you want CI to honor.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Swap the dependency install step in CI to use uv and make it “frozen” so CI fails if the lock-file is stale.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Write a short local workflow in the README: install uv, sync dependencies, run tests. One page. No tribal knowledge.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In GitHub Actions, the “swap the install step” part can be as small as this:&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="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;Install dependencies (locked)&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;uv sync --frozen&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Define “done” before you start. Good definitions of done are observable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;CI is green and the dependency step is shorter (or at least more stable) on cold caches.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;One teammate who did not touch the pilot can clone the repo and run tests on a clean machine in under X minutes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;You can point to a single default command for “get me to a working environment.”&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also define the rollback in one sentence. This lowers the political risk of trying something new. For example: “If the pilot burns more than half a day of engineer time or destabilizes CI, we revert the CI install step and keep the lockfile as-is.” You are buying information, not committing the company.&lt;/p&gt;

&lt;p&gt;If the pilot works, resist the urge to immediately migrate every repo. The value of the pilot is not the migration - it is the evidence and the template. Capture both, then write a short policy that makes the choice automatic for every new repo from here on.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Minimal uv Team Policy
&lt;/h2&gt;

&lt;p&gt;If you want the benefits of uv without turning it into a prolonged debate, write down a minimal policy and enforce it in CI. The policy is not “everyone must love this tool.” The policy is “we have one default workflow and CI proves it.”&lt;/p&gt;

&lt;p&gt;Here is a paste-ready version you can drop into your engineering handbook or an ADR.&lt;/p&gt;

&lt;h3&gt;
  
  
  Policy (minimal)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Default package workflow&lt;/strong&gt;: For Python repos, uv is the default tool for creating environments and installing dependencies.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Lock-files are required&lt;/strong&gt;: Dependency changes must update the lock-file and commit it to git.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;CI is authoritative&lt;/strong&gt;: CI installs dependencies from the lock-file in a frozen mode. If the lock-file is stale, CI fails.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Local workflow matches CI&lt;/strong&gt;: The same “sync from lock-file” approach is the expected developer path.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want a single line to standardize on in CI, use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv &lt;span class="nb"&gt;sync&lt;/span&gt; &lt;span class="nt"&gt;--frozen&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Link to the official uv documentation for the exact installation steps and any platform-specific details. Your handbook should own the rules, not the flags.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Onboarding checklist (one page)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Install uv&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Clone the repo&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Sync dependencies from the lock-file&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Run tests&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the whole point: fewer hidden steps and fewer Slack rituals.&lt;/p&gt;

&lt;h3&gt;
  
  
  Governance (light-touch)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Exceptions&lt;/strong&gt;: Any repo can opt out temporarily, but the exception needs a named owner and an expiration date. Otherwise “temporary” becomes permanent.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;New repos&lt;/strong&gt;: Provide a template repo (or a copy-paste snippet for CI) that already follows the policy, so teams do not reinvent it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Decision log&lt;/strong&gt;: If your team uses ADRs, write a one-page “Why uv?” note that captures the motivation (cycle time, reproducibility) and the rollback trigger.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to make that decision durable, use a tiny template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## ADR: Standardize Python dependency workflow on uv&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; &lt;span class="gs"&gt;**Status**&lt;/span&gt;: Proposed / Accepted / Rejected
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Why now**&lt;/span&gt;: CI minutes + onboarding time + tool sprawl
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Decision**&lt;/span&gt;: uv is the default; lockfiles required; CI uses &lt;span class="sb"&gt;`uv sync --frozen`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Pilot**&lt;/span&gt;: repo = &lt;span class="sb"&gt;`your-repo`&lt;/span&gt;; start = 2026-05-27; success metric = “save 10 minutes per PR in CI dependency step”
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Rollback**&lt;/span&gt;: if CI becomes flaky or the pilot consumes &amp;gt; 0.5 day of engineer time, revert the CI install step and stop the rollout
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is what standardization buys you: fewer repo-by-repo decisions, fewer environment surprises, and a workflow you can explain in five minutes to a new hire. uv is not the only way to get there, but right now it is one of the lowest-friction ways for Python teams to make the default path fast and consistent.&lt;/p&gt;

&lt;p&gt;Run the one-week pilot. Measure one metric you care about. If the numbers agree, expand. If they do not, stop and move on.&lt;/p&gt;




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

&lt;p&gt;Python tooling debates are a proxy for a deeper question: does your team have a default, or does every service get to decide? uv does not answer that question for you. It lowers the cost of picking an answer.&lt;/p&gt;

&lt;p&gt;The pattern is simple. You are already paying for slow installs, broken local environments, and tool sprawl - you are just paying in ways that do not show up on a dashboard. A half-day pilot on one noisy repo will tell you whether the numbers justify expanding. A short policy will tell your team what the default is from here on.&lt;/p&gt;

&lt;p&gt;That is all this is: one default, enforced in CI, documented in your handbook. The benefit is not speed for its own sake. It is fewer conversations about tooling and more conversations about the work.&lt;/p&gt;




&lt;p&gt;Follow me on Twitter: &lt;a href="https://twitter.com/DevAsService" rel="noopener noreferrer"&gt;https://twitter.com/DevAsService&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on Instagram: &lt;a href="https://www.instagram.com/devasservice/" rel="noopener noreferrer"&gt;https://www.instagram.com/devasservice/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on TikTok: &lt;a href="https://www.tiktok.com/@devasservice" rel="noopener noreferrer"&gt;https://www.tiktok.com/@devasservice&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow me on YouTube: &lt;a href="https://www.youtube.com/@DevAsService" rel="noopener noreferrer"&gt;https://www.youtube.com/@DevAsService&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>uv</category>
      <category>teams</category>
    </item>
  </channel>
</rss>
