<?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: Evgeny Khramov</title>
    <description>The latest articles on DEV Community by Evgeny Khramov (@hram).</description>
    <link>https://dev.to/hram</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%2F4142952%2F368dd7ed-35dc-43c5-968d-6e04db62e780.png</url>
      <title>DEV Community: Evgeny Khramov</title>
      <link>https://dev.to/hram</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hram"/>
    <language>en</language>
    <item>
      <title>STL to BREP: A FreeCAD Tool for AI Agents</title>
      <dc:creator>Evgeny Khramov</dc:creator>
      <pubDate>Sat, 31 Oct 2026 17:24:49 +0000</pubDate>
      <link>https://dev.to/hram/stl-to-brep-a-freecad-tool-for-ai-agents-2nik</link>
      <guid>https://dev.to/hram/stl-to-brep-a-freecad-tool-for-ai-agents-2nik</guid>
      <description>&lt;p&gt;I asked an acquaintance who works in CAD professionally what is still missing from working with CAD through an AI agent. The first item on their list was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Automatic stitching of STL into BREP solids.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I decided to start there. A mesh-to-solid converter already sounds solved, so the interesting part is what "stitched" actually gives you.&lt;/p&gt;

&lt;h2&gt;
  
  
  A valid solid, and still faceted
&lt;/h2&gt;

&lt;p&gt;An STL file is a triangle mesh. Stitch it into a closed BREP solid and you get something valid, but a round hole can remain a ring of narrow flat faces. Here is the same part, &lt;code&gt;tiltservomount_std.stl&lt;/code&gt; (2884 triangles), stitched without surface recognition:&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%2Fptfngy1sppbwbiju6t2l.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%2Fptfngy1sppbwbiju6t2l.png" alt="Plain STL-to-BREP stitching: 808 flat faces, the holes and the lug remain faceted." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;808 faces, every one of them a plane. The solid is valid, but the holes and the lug are made of narrow flat strips, not cylindrical surfaces.&lt;/p&gt;

&lt;p&gt;I wanted the tool to recognize planes and cylindrical regions in the mesh and replace them with real analytic BREP surfaces. The result for the same file:&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%2Fg82p9rtbsrhjd0prixrk.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%2Fg82p9rtbsrhjd0prixrk.png" alt="The same STL after convert_mesh_to_brep: 31 faces, cylindrical surfaces highlighted in orange." width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;31 faces: 21 &lt;code&gt;Plane&lt;/code&gt; and 10 &lt;code&gt;Cylinder&lt;/code&gt;. The surface types are read from the BREP itself; the orange color is applied for the figure only. The tool is &lt;code&gt;convert_mesh_to_brep&lt;/code&gt; in my &lt;a href="https://github.com/hram/freecad-mcp" rel="noopener noreferrer"&gt;open-source fork of FreeCAD MCP&lt;/a&gt;, so an AI agent driving FreeCAD can call it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was checked, and what was not
&lt;/h2&gt;

&lt;p&gt;I ran six STL files from &lt;a href="https://www.thingiverse.com/thing:1941574" rel="noopener noreferrer"&gt;Pan and Tilt for Standard Servo&lt;/a&gt; by micromet (CC BY-SA) through a single tool, with &lt;code&gt;repair=true&lt;/code&gt; and default tolerances, without selecting faces or dimensions by hand.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;All six results are closed, valid solids, and they survived a save-and-reopen test.&lt;/li&gt;
&lt;li&gt;The volume change on every part is under 0.04%.&lt;/li&gt;
&lt;li&gt;A residual-faceting scanner found no remaining chains of faces it knows how to recognize.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is a weaker claim than it sounds, and I want to be specific about why:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;All six parts come from one set, and the tool was developed against them.&lt;/li&gt;
&lt;li&gt;Close volumes do not prove accurate surfaces. I did not measure the maximum distance between the source mesh and the result.&lt;/li&gt;
&lt;li&gt;A closed solid does not mean the cylinders were recovered. That is why the report keeps BREP validity and residual faceting (&lt;code&gt;reconstruction_status&lt;/code&gt;) as separate fields.&lt;/li&gt;
&lt;li&gt;NURBS surfaces, cones and spheres are not reconstructed, and the tool does not recover the part's modeling history.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where I need your help
&lt;/h2&gt;

&lt;p&gt;I am not a CAD professional, and six parts is clearly not enough. I would like to find STL files that break this tool: cylinders left faceted, wrongly recognized surfaces, distorted holes, extra geometry, or a mesh it cannot process at all.&lt;/p&gt;

&lt;p&gt;If you try it, a problem STL, a screenshot of expected vs. actual, your FreeCAD version and the JSON report help the most. For commercial or confidential models, an anonymized or deliberately simplified example that reproduces the defect is enough. Bugs and ideas go to &lt;a href="https://github.com/hram/freecad-mcp/issues" rel="noopener noreferrer"&gt;GitHub Issues&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The full write-up is on my site: the multi-pass pipeline (up to four recognition passes), the per-part results table, a working call example and the report fields: &lt;a href="https://hram.github.io/en/articles/freecad-mcp-mesh-to-brep/" rel="noopener noreferrer"&gt;https://hram.github.io/en/articles/freecad-mcp-mesh-to-brep/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>freecad</category>
      <category>cad</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I Extended FreeCAD MCP After 1.5 Hours of Assembling Through Chat. Then the Agents Built It Alone</title>
      <dc:creator>Evgeny Khramov</dc:creator>
      <pubDate>Wed, 28 Oct 2026 11:13:44 +0000</pubDate>
      <link>https://dev.to/hram/i-extended-freecad-mcp-after-15-hours-of-assembling-through-chat-then-the-agents-built-it-alone-1cba</link>
      <guid>https://dev.to/hram/i-extended-freecad-mcp-after-15-hours-of-assembling-through-chat-then-the-agents-built-it-alone-1cba</guid>
      <description>&lt;p&gt;"No, you rotated them, I asked you to swap them."&lt;/p&gt;

&lt;p&gt;That's a message I sent to a coding agent that was supposed to assemble a two-axis pan-tilt mechanism in FreeCAD. A bit later I was telling it which part to rotate, around which axis, by how many degrees, and then along which single coordinate to shift it. The agent executed CAD operations, but I was the one assembling, through a chat. It took &lt;strong&gt;1 hour 37 minutes&lt;/strong&gt;, and the base was still unassembled.&lt;/p&gt;

&lt;p&gt;So I extended the MCP server: &lt;a href="https://github.com/hram/freecad-mcp" rel="noopener noreferrer"&gt;hram/freecad-mcp&lt;/a&gt;, a fork of &lt;a href="https://github.com/neka-nat/freecad-mcp" rel="noopener noreferrer"&gt;neka-nat/freecad-mcp&lt;/a&gt; (MIT). Afterwards, Claude and Codex each assembled the same mechanism on their own, in about 15 and 17 minutes. But the geometry checks in the new tools missed a mistake: Codex had swapped the two side plates.&lt;/p&gt;

&lt;p&gt;This post covers the tool, the two runs, and what the checks can and can't catch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The task and the first attempt
&lt;/h2&gt;

&lt;p&gt;I'm building a platform that rotates a phone in two planes, &lt;em&gt;pan&lt;/em&gt; and &lt;em&gt;tilt&lt;/em&gt;, with two MG996R servos. I'm a software developer, not a CAD user. I used the &lt;a href="https://www.thingiverse.com/thing:1941574" rel="noopener noreferrer"&gt;Pan and Tilt for Standard Servo&lt;/a&gt; design by micromet. The archive has five printed parts (base, rotating tray, two different side plates, top platform), a sixth STL for an IR sensor mount, and no CAD assembly or servo models.&lt;/p&gt;

&lt;p&gt;On October 26 I gave Codex the archive. A good-looking screenshot wasn't enough to tell whether things were placed correctly: one part ended up inside another, and when I asked it to swap the side plates it rotated them instead. After a few rounds I switched to commanding each step. My messages from the log (originally in Russian):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"no you rotated them and I asked to swap them"&lt;/p&gt;

&lt;p&gt;"let's start over. lay out all the parts. I'll tell you step by step what to assemble with what"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;From there I chose axes and angles, picked which hole to align with which, forbade touching neighboring parts, limited corrections to a single coordinate, and asked for rollbacks. The 1 hour 37 minutes is time to partial acceptance. It isn't a benchmark, only an illustration of how much human involvement the process needed. What bothered me wasn't that the agent made mistakes; it was that it couldn't reliably detect and fix them without me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "script executed" isn't enough
&lt;/h2&gt;

&lt;p&gt;Take a typical task: bolt a bracket to a base through two holes. With upstream, the agent writes the Python itself: finds the faces, converts coordinates to world space, computes a Placement, applies it. Then it gets this back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;execute_code(&amp;lt;own script: find faces → compute Placement → apply&amp;gt;)
→ "Script executed successfully"
get_view()  → one picture from one angle
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the holes are off by 0.3 mm, you only see it by eye. A single projection doesn't answer whether holes are coaxial, what the gap between planes is, or whether parts intersect. I asked a different agent to assess the toolset and its assessment matched my concern; then I had a coding agent extend the server. That was my judgment call, not a proven root cause of the earlier failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the fork does
&lt;/h2&gt;

&lt;p&gt;The fork keeps all 17 original tools unchanged and adds 14. The mounting task becomes a sequence of ready-made operations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;define_interface(base)          define_interface(bracket)      # which face, which holes
plan_alignment(...)             # a plan; the document is not changed
preview_alignment(plan_id)      # the part in its proposed position, 3 views
apply_alignment(plan_id)        # move in a transaction; roll back if tolerance is violated
check_assembly(plan_id)         # independent re-check
inspect_region(...)             # isometric + top + right in one response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result is measurements, not "success". Real output from a test scene (FreeCAD 1.1.4, add-on 0.2.1): two 40×24×6 mm blocks with two holes each, bracket initially offset and rotated.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;passed: true
max_hole_error_mm: 6.2e-14      hole_axis_angles_deg: [0.0, 0.0]
gap_mm: 3.6e-14                 opposing_faces_error_deg: 0.0
collisions: [{part: "Base", overlap_mm3: 0.0}]
collision_scope: ["Base"]       whole_assembly_checked: false
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Values around 1e-14 mm are floating-point noise, so the holes match exactly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8bq4ymtk6t1lltuxfp2o.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8bq4ymtk6t1lltuxfp2o.gif" alt="Verified mounting cycle: initial position, preview, apply with measurements" width="800" height="356"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The cycle on that same test scene.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Design details that matter in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Holes are ordered.&lt;/strong&gt; &lt;code&gt;define_interface&lt;/code&gt; takes a contact plane and at least two holes in a given order. The tool doesn't guess which side of a part faces outward or resolve symmetry; the agent has to decide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apply is a transaction.&lt;/strong&gt; &lt;code&gt;apply_alignment&lt;/code&gt; moves the part, recomputes, re-measures, and rolls back if hole match, axis angle, contact gap or intersection volume violates the tolerance. It doesn't save the file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plans go stale safely.&lt;/strong&gt; Geometry fingerprints, a document change counter and document identity mean a plan built before the model changed is rejected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long operations are jobs.&lt;/strong&gt; A normal call waits up to 20 seconds, then returns a &lt;code&gt;job_id&lt;/code&gt;. With &lt;code&gt;start_assembly_operation&lt;/code&gt;, a repeated &lt;code&gt;request_id&lt;/code&gt; with the same arguments returns the same job instead of re-running the move. &lt;code&gt;outcome_unknown&lt;/code&gt; is reported instead of a false error when the GUI stalls, and cancellation works between safe stages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unsaved state is visible.&lt;/strong&gt; &lt;code&gt;get_document_status&lt;/code&gt; reports the GUI modified flag, whether the file exists, and whether a recompute is needed, as separate fields.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permanent links.&lt;/strong&gt; &lt;code&gt;create_assembly&lt;/code&gt; and &lt;code&gt;create_fixed_joint&lt;/code&gt; use FreeCAD's own Fixed/Grounded Joint, with a geometry check after the solver runs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The new operations need the fork's own add-on (0.2.1); &lt;code&gt;scripts/install_addon.py&lt;/code&gt; installs it and keeps a backup of the previous one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two agents, no coordinates from me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Claude, 15 min 7 s.&lt;/strong&gt; On October 27 I asked Claude to assemble the same archive on its own. When it started reading an earlier project of mine, I stopped it: "forget the stand project. do it yourself, don't peek." It had already seen some service information and earlier commit titles, so the run isn't perfectly isolated, but after that I gave no placement hints. It reconstructed the five parts as geometry suitable for face and cylinder analysis, checked volumes against the source models (difference up to 0.04%), added two simplified servo models and a horn, checked body pairs for intersections, saved an FCStd and rendered four views.&lt;/p&gt;

&lt;p&gt;One caveat from the logs: Claude did the main layout with its own Python scripts and used the new alignment API on the two side plates only afterwards, to verify and reproduce their position. So this isn't an isolated test of each new command.&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%2F30fj7xb5gul0f7zzmw9d.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%2F30fj7xb5gul0f7zzmw9d.png" alt="Four-view render of Claude's autonomous pan-tilt assembly" width="800" height="846"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Claude's own collage, unretouched. The mechanism looks right, but the green caption inside the image, "all axes aligned to 1e-6 mm, no intersections", is wrong: the measurements are below.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Codex, about 17 min 29 s.&lt;/strong&gt; I repeated the task in Codex to rule out "it's just a different model". It also finished without step-by-step instructions, this time using &lt;code&gt;define_interface&lt;/code&gt;, &lt;code&gt;plan_alignment&lt;/code&gt;, &lt;code&gt;preview_alignment&lt;/code&gt;, &lt;code&gt;apply_alignment&lt;/code&gt;, &lt;code&gt;check_assembly&lt;/code&gt; and &lt;code&gt;inspect_region&lt;/code&gt; for a number of joints. It created two placeholder servo models, horns, and also included the sixth archive part, the IR sensor mount.&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%2Fi3vdh22c3q66jijkzx9o.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%2Fi3vdh22c3q66jijkzx9o.png" alt="Four-view render of Codex's autonomous assembly with the side plates swapped" width="800" height="850"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Codex's result, deliberately left uncorrected. The side plates are in each other's places.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the checks missed
&lt;/h2&gt;

&lt;p&gt;When I looked at the Codex model, the side plates were in the wrong places. Codex had also added four 1 mm spacers that weren't in the original design, while trying to remove intersections near the top. &lt;strong&gt;In my assessment the spacers were needed because of the swap&lt;/strong&gt;: instead of fixing the placement, the agent compensated with extra parts. That causal link is my reading of the result; I did not run an experiment with the spacers removed and the plates swapped back.&lt;/p&gt;

&lt;p&gt;Meanwhile its checks showed hole deviation below &lt;code&gt;5e-14&lt;/code&gt; mm for the joints it had defined, zero angular error, and no volumetric intersections among 91 pairs of bodies in the neutral pose.&lt;/p&gt;

&lt;p&gt;There's no contradiction. The tool verified what the agent itself declared correct: the chosen pair of holes, the allowed gap, no intersection at the checked position. It did not verify whether the agent understood which side plate is left and which is right. You can align the wrong parts, the wrong way round, with mathematical precision.&lt;/p&gt;

&lt;p&gt;Claude's checks weren't clean either. Its final measurements showed an axis offset of &lt;strong&gt;0.5831 mm&lt;/strong&gt;, up to &lt;strong&gt;0.25 mm&lt;/strong&gt; across matched mounts, one hole without a match, and an &lt;strong&gt;84.823 mm³&lt;/strong&gt; intersection between the placeholder servo shaft and horn disk. Its final image nevertheless claimed all axes aligned to &lt;code&gt;1e-6&lt;/code&gt; mm with no intersections. One of its own checks had also first shown zero deviations because, before measuring, it restored the reference positions of the parts; the agent noticed the suspicious zeros and fixed the script. Verification code can be wrong in the same way the thing it verifies can.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits of the tool and of my claims
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Collision checks are partial.&lt;/strong&gt; Intersections are checked only between the moving part, the fixed part and the &lt;code&gt;obstacles&lt;/code&gt; you list. Every response says &lt;code&gt;whole_assembly_checked: false&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The agent still chooses.&lt;/strong&gt; Picking the contact faces and the hole order stays with the agent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not supported yet:&lt;/strong&gt; &lt;code&gt;App::Link&lt;/code&gt;, moving &lt;code&gt;App::Part&lt;/code&gt; containers, and editing arbitrary existing networks of Assembly constraints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tested scope.&lt;/strong&gt; 286 tests pass on the version I examined, and live smoke scenarios cover nested Placements, PartDesign Body, rollback on collision and cancel, preview, and Fixed Joint after save and reopen. That's tested scenarios, not a guarantee for an arbitrary CAD model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timings aren't a speed metric.&lt;/strong&gt; The runs differ in agent, context, geometry representation and set of parts, so I can't say the tools made assembly six times faster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No physical result yet.&lt;/strong&gt; The servos were simplified CAD mockups. I haven't shown that real MG996R servos fit without rework or that the mechanism survives a phone's load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When upstream is enough.&lt;/strong&gt; If you draw parts, edit objects, run FEM or generate models by script, you don't need the new tools.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What did change is my role. On day 1 I explained how to rotate and where to shift. On day 2 I said what to assemble and reviewed the result. When Codex erred, I didn't re-teach it geometric transformations; I spotted a wrong engineering decision on a finished model. Checking how precisely parts are joined is now automated; checking whether the agent chose the right parts to join is still on me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&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;uv run freecad-mcp &lt;span class="nt"&gt;--host&lt;/span&gt; 127.0.0.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You need Python 3.12+, uv, and FreeCAD running with the MCP RPC on port 9875, plus the fork's add-on. Details are in the README: &lt;a href="https://github.com/hram/freecad-mcp" rel="noopener noreferrer"&gt;hram/freecad-mcp&lt;/a&gt;. The mechanical models and assembly images in this post are based on micromet's design, distributed under Creative Commons Attribution–ShareAlike (the license version wasn't established in my research); the MCP's MIT license doesn't apply to them.&lt;/p&gt;

&lt;p&gt;I keep the full case study, with the longer chronology, on my site: &lt;a href="https://hram.github.io/en/articles/freecad-mcp/" rel="noopener noreferrer"&gt;https://hram.github.io/en/articles/freecad-mcp/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>cad</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Don't write the prompt. Get interviewed</title>
      <dc:creator>Evgeny Khramov</dc:creator>
      <pubDate>Thu, 22 Oct 2026 06:03:54 +0000</pubDate>
      <link>https://dev.to/hram/dont-write-the-prompt-get-interviewed-hp7</link>
      <guid>https://dev.to/hram/dont-write-the-prompt-get-interviewed-hp7</guid>
      <description>&lt;p&gt;Everyone teaches you to write prompts. In practice, a person looks at a sample a couple of pages long, with a stack, constraints, formats and edge cases, and closes the tab: there's no way I'll write that.&lt;/p&gt;

&lt;p&gt;I needed a document camera: an Android phone on a tripod above the desk, a live preview in the PaperLedger browser page and a "Capture" button. Instead of a technical specification I told the agent: interview me, find out the stack, the architecture and the boundaries of the decisions.&lt;/p&gt;

&lt;p&gt;An hour later there were 72 messages from me and 71 answers from the agent. Some of the questions were ones I would never have thought of myself: for example, what happens if the portal is open in several tabs at once. There was also a mistake by the agent: it questioned me at length about integration with PaperLedger before looking at its code.&lt;/p&gt;

&lt;p&gt;I never wrote the final "prompt". It came together from the answers: the scenario, the boundaries, the contract between the phone and the portal. Then, on the "get started" command, the agent built the app, moved into the Python portal and connected both sides.&lt;/p&gt;

&lt;p&gt;If a prompt feels out of reach, don't start with it. Ask the agent to ask you one question at a time and answer as you would. The decisions you would have made anyway end up written down.&lt;/p&gt;

&lt;p&gt;The whole interview, without cuts, and what the agent did after it, are in the article: &lt;a href="https://hram.github.io/en/articles/mobile-ledger/" rel="noopener noreferrer"&gt;https://hram.github.io/en/articles/mobile-ledger/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>android</category>
      <category>kotlin</category>
      <category>programming</category>
    </item>
    <item>
      <title>My Coding Agent Kept Asking Which Branch to Use. Now It Asks an MCP That Reads Merged MRs</title>
      <dc:creator>Evgeny Khramov</dc:creator>
      <pubDate>Sun, 18 Oct 2026 11:15:07 +0000</pubDate>
      <link>https://dev.to/hram/my-coding-agent-kept-asking-which-branch-to-use-now-it-asks-an-mcp-that-reads-merged-mrs-4nip</link>
      <guid>https://dev.to/hram/my-coding-agent-kept-asking-which-branch-to-use-now-it-asks-an-mcp-that-reads-merged-mrs-4nip</guid>
      <description>&lt;p&gt;I used to tell my coding agent something like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Update &lt;code&gt;develop/1.65.0&lt;/code&gt;. Create &lt;code&gt;feature/&amp;lt;task key&amp;gt;&lt;/code&gt; from it. Implement the task. Then make an MR into &lt;code&gt;develop/1.65.0&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Tomorrow the same release could already be in &lt;code&gt;alpha&lt;/code&gt;, and a bit later in &lt;code&gt;beta&lt;/code&gt;. So I had to know the current stage of every release myself and pass the right branches to the agent every time.&lt;/p&gt;

&lt;p&gt;The release stage was the one piece of my release process that lived outside Git, CI and Jira: in people's heads and in a Confluence table I updated by hand. This post is about how I made it computable, first for myself in a portal, then for the agent as an MCP tool, and where the algorithm can be wrong.&lt;/p&gt;

&lt;p&gt;Some context. In my Git Flow every release has its own chain of branches, &lt;code&gt;stage/version&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;develop/1.65.0 → alpha/1.65.0 → beta/1.65.0 → prod/1.65.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Several versions live at the same time: one in development, one in testing, one in beta, one in production. Fixes made in an older version must be carried into newer ones. The model is described in &lt;a href="https://hram.github.io/en/articles/hram-git-flow-base/" rel="noopener noreferrer"&gt;part 1&lt;/a&gt;, and the CI checks built on top of it in &lt;a href="https://hram.github.io/en/articles/hram-git-flow-ci/" rel="noopener noreferrer"&gt;part 2&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Branches don't tell you the stage
&lt;/h2&gt;

&lt;p&gt;The obvious algorithm, "find the latest existing branch", does not work. When a new release is set up, the branches of all its stages are created at once. &lt;code&gt;prod/1.65.0&lt;/code&gt; exists from day one, even if the release has only just started being developed.&lt;/p&gt;

&lt;p&gt;A different signal is needed: the action by which, according to our process, a release is moved forward. For us that action is &lt;strong&gt;a merged merge request into the branch of the corresponding stage&lt;/strong&gt;. The target branch is named &lt;code&gt;&amp;lt;stage&amp;gt;/&amp;lt;version&amp;gt;&lt;/code&gt;, and the MR is linked to a Jira task. If a matching MR was merged into &lt;code&gt;alpha/1.65.0&lt;/code&gt;, the release reached &lt;code&gt;alpha&lt;/code&gt;. If such an MR later appeared in &lt;code&gt;beta/1.65.0&lt;/code&gt;, it reached &lt;code&gt;beta&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The tool finds merged MRs with a Jira key in the title, branch name or description, matches their target branches against the release and the order of stages, and picks &lt;strong&gt;the latest stage that has such evidence&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Release 1.65.0

Branch                Merged MR as evidence?

develop/1.65.0        yes
alpha/1.65.0          yes                ← current stage
beta/1.65.0           no
prod/1.65.0           no

Result: stage = alpha, baseBranch = alpha/1.65.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa4e5y4fvhe1bvgzohmd0.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%2Fa4e5y4fvhe1bvgzohmd0.png" alt="Release 1.65.0: the branches develop, alpha, beta and prod all exist, but a merged MR with a Jira key exists only in develop and alpha; result: stage = alpha, baseBranch = alpha/1.65.0. The data is fictional" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is not a universal property of Git. It is &lt;strong&gt;our definition of a release state&lt;/strong&gt;, and it works because the process was agreed in advance. If your team moves stages in a different way, you need different evidence.&lt;/p&gt;

&lt;p&gt;I am stating this definition in hindsight. At first I only wanted to get rid of the table. The formal algorithm and its limits took shape while I was building the tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  The agent side: an MCP that answers "where do I start?"
&lt;/h2&gt;

&lt;p&gt;I did not want the agent to guess the release state from a local checkout, pick the default branch, or memorise each project's quirks in a prompt. It needed a tool that answers one concrete question: &lt;strong&gt;where do I start work on this Jira task, and where do I send the MR afterwards?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That tool is an MCP server I called &lt;strong&gt;Delivery Manager&lt;/strong&gt;. The name can mislead: it does not replace a real delivery manager. A human still plans releases and sets the Fix Version in Jira. The MCP receives a task that is already planned and works out how to handle it within the existing release process.&lt;/p&gt;

&lt;p&gt;Given the project, the version and the task key, it queries GitLab, determines the current stage and returns parameters for the work. Schematically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Fictional example; a shortened illustration of the contract,&lt;/span&gt;
&lt;span class="c1"&gt;# not the literal JSON response of the MCP.&lt;/span&gt;
&lt;span class="na"&gt;fixVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1.65.0&lt;/span&gt;
&lt;span class="na"&gt;currentStage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;alpha&lt;/span&gt;
&lt;span class="na"&gt;baseBranch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;alpha/1.65.0&lt;/span&gt;
&lt;span class="na"&gt;baseBranchExists&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;featureBranch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;feature/DEMO-123&lt;/span&gt;
&lt;span class="na"&gt;mergeRequest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;targetBranch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;alpha/1.65.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent does not need to understand why &lt;code&gt;1.65.0&lt;/code&gt; is on &lt;code&gt;alpha&lt;/code&gt; today. It gets a verified &lt;code&gt;baseBranch&lt;/code&gt; and the MR target branch.&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%2Ffq8t0l0l25bcmj4e24za.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%2Ffq8t0l0l25bcmj4e24za.png" alt="Diagram: a Jira task with a Fix Version goes to the coding agent, the agent calls the MCP Delivery Manager, which reads Jira and GitLab and returns baseBranch, featureBranch and the MR target branch, or a reason to stop. The data is fictional" width="800" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The rule I gave the agent is hard: &lt;strong&gt;it does not choose the base release branch on its own. The only source is the release MCP.&lt;/strong&gt; Work does not start by guesswork if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the task has no Fix Version;&lt;/li&gt;
&lt;li&gt;the current stage cannot be determined;&lt;/li&gt;
&lt;li&gt;the needed release branch is missing;&lt;/li&gt;
&lt;li&gt;GitLab is unavailable;&lt;/li&gt;
&lt;li&gt;the task is not in the "To Do" status, so it cannot be considered prepared for implementation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last one fired in a quite down-to-earth case: I forgot to move a task to the right status myself, and the work stopped. That is not a tool error. It refused to bypass the process just to start writing code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the algorithm came from: a portal instead of a Confluence table
&lt;/h2&gt;

&lt;p&gt;The algorithm did not start in the MCP. Before that I had a Confluence table: rows are release numbers, columns are stages and operations. When a release moved on, I wrote in the date, removed the green fill from one cell and painted another. Its accuracy depended on me, while GitLab had already recorded every action I was copying by hand.&lt;/p&gt;

&lt;p&gt;I am an Android developer and had never written web portals. I discussed the idea in ChatGPT, got an implementation prompt and handed it to a coding agent. That is how my &lt;code&gt;gitlab-panel&lt;/code&gt; portal started. It builds the picture from GitLab data: a matrix with versions as rows and stages as columns.&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%2F0lhsmgwfcz36czezh4jl.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%2F0lhsmgwfcz36czezh4jl.png" alt="Release matrix: rows are versions, columns are stages; horizontal arrows show the transfer between stages inside a release, the number on a red arrow is how many commits are not transferred yet; dashed links between versions show whether fixes were transferred into the newer release. The data is fictional" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The matrix shows two directions of change flow. Horizontally, changes go through the stages inside one release. Vertically, the portal checks the rule "fixes from an older release must reach the newer one": it compares the branches of neighbouring releases and finds commits present in the older version but absent from the newer one, with merge commits filtered out. That rule used to be just an obligation of the team.&lt;/p&gt;

&lt;p&gt;From an arrow in the matrix the portal can also start a "pour", our word for transferring changes between stage branches or between releases. I confirm the source, target and changes; the portal uses an existing Jira task or creates a sub-task, moves it to &lt;code&gt;CodeReview&lt;/code&gt; (and stops if that fails), creates a temporary &lt;code&gt;feature/&amp;lt;KEY&amp;gt;&lt;/code&gt; from the source branch and an MR into the target. Squash is deliberately off, so the commit history can later be used to check how commits moved between releases. The portal also tries to enable merge after a successful pipeline, but that is not guaranteed; some steps may need attention.&lt;/p&gt;

&lt;p&gt;The portal does not decide whether it is time for a release to move to the next stage. A tester reports readiness, a delivery manager plans further progress and creates the pour task. What is automated is the actions after the decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two implementations, no shared library
&lt;/h2&gt;

&lt;p&gt;The portal and Delivery Manager are separate projects with separate codebases. I did not make the MCP depend on a running portal.&lt;/p&gt;

&lt;p&gt;When the MCP was needed, I asked the agent working on the portal to explain where exactly in the source the stage-detection algorithm lives: files, methods, logic. I handed that note to another agent, the one building the MCP, and it carried the domain idea over.&lt;/p&gt;

&lt;p&gt;So there are now two implementations that rely on one process and on the GitLab history, but &lt;strong&gt;there is no automatic code synchronisation between them&lt;/strong&gt;. Stage order is another compromise: in the MCP it is established from the project's release branches and known stage names, then fixed in the space settings; in the portal there are separate screens for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it can be wrong
&lt;/h2&gt;

&lt;p&gt;The computation rests on three assumptions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The stage is the furthest confirmed transition.&lt;/strong&gt; A merged MR into &lt;code&gt;beta/&amp;lt;version&amp;gt;&lt;/code&gt; means the release reached &lt;code&gt;beta&lt;/code&gt;. The algorithm does not try to reconstruct business logic from task names.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A release moves only forward.&lt;/strong&gt; My model has no rollback from &lt;code&gt;beta&lt;/code&gt; to &lt;code&gt;alpha&lt;/code&gt;, so the latest confirmed stage stays current. If your team can officially roll stages back, this algorithm is not enough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No confirming MRs means the first existing stage.&lt;/strong&gt; That is the normal state of a freshly created release: all branches exist, no pour has happened yet. It is a deliberate fallback, not a proven transition.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhf64afe8cpc164zim94z.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%2Fhf64afe8cpc164zim94z.png" alt="Three scenarios for release 1.65.0: merged MRs in develop, alpha and beta give stage = beta (proven by history); an MR into alpha after beta does not change the result, rollback is not modelled; branches without merged MRs give the first stage by convention. The data is fictional" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There are engineering limits too: two implementations mean a rule change has to be ported between them by hand. The stage check did not become part of the old &lt;code&gt;jira:check&lt;/code&gt; CI script from part 2; other tools do it now. And I did not measure how many hours this saved, so I will not quote percentages.&lt;/p&gt;

&lt;p&gt;What changed is easy to describe without numbers. I used to remember the release stage, update the table, explain branches to a person or an agent and run a sequence of actions in GitLab. Now the state comes from history, the portal shows discrepancies, and the agent asks the MCP instead of asking me.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would reuse
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pick the event in Git that counts as evidence of a stage&lt;/strong&gt; in your process. For me it is a merged MR with a task key into &lt;code&gt;&amp;lt;stage&amp;gt;/&amp;lt;version&amp;gt;&lt;/code&gt;. Branch existence is not evidence if branches are created up front.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide explicitly what happens when there is no evidence yet&lt;/strong&gt; and which movements your algorithm does not model, such as rollbacks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give the agent a tool that answers "from where and to where"&lt;/strong&gt;, instead of putting branch names in the prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;List the stop conditions.&lt;/strong&gt; If the tool cannot answer with justification, the right action is to stop, not to guess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the decision to move a stage with people.&lt;/strong&gt; Automate what follows the decision.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full case study, including the pour wizard and the two-directions diagram, is on my site: &lt;a href="https://hram.github.io/en/articles/hram-git-flow-ai/" rel="noopener noreferrer"&gt;https://hram.github.io/en/articles/hram-git-flow-ai/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>git</category>
      <category>gitlab</category>
      <category>mcp</category>
      <category>ai</category>
    </item>
    <item>
      <title>Versioned Branch Names Let GitLab CI Enforce My Release Process Instead of Me</title>
      <dc:creator>Evgeny Khramov</dc:creator>
      <pubDate>Thu, 15 Oct 2026 18:49:24 +0000</pubDate>
      <link>https://dev.to/hram/versioned-branch-names-let-gitlab-ci-enforce-my-release-process-instead-of-me-289f</link>
      <guid>https://dev.to/hram/versioned-branch-names-let-gitlab-ci-enforce-my-release-process-instead-of-me-289f</guid>
      <description>&lt;p&gt;We agreed on the release process three times. I drew diagrams, explained where to merge changes and who decides when a release moves forward. In practice I still had to explain the rules again and again: some people did not understand which branch to pick, others mixed up the order of steps, and others frankly did not want to follow them.&lt;/p&gt;

&lt;p&gt;In a presentation I prepared for colleagues later there is a phrase: &lt;em&gt;"I felt like an overseer."&lt;/em&gt; That describes that period well.&lt;/p&gt;

&lt;p&gt;So I decided: if a rule can be checked by a program, nobody has to stand next to every merge request. This is what that looked like in GitLab CI. The branching model itself, which I have used for more than ten years, is described in &lt;a href="https://hram.github.io/en/articles/hram-git-flow-base/" rel="noopener noreferrer"&gt;part 1&lt;/a&gt;: versioned &lt;code&gt;stage/version&lt;/code&gt; branches, and release scope decided by planning, not rebuilt by developers with &lt;code&gt;cherry-pick&lt;/code&gt; and &lt;code&gt;revert&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The starting point: buttons you have to babysit
&lt;/h2&gt;

&lt;p&gt;The project I joined had plain &lt;code&gt;develop&lt;/code&gt; and &lt;code&gt;master&lt;/code&gt;. A deploy could be started manually for different environments: from &lt;code&gt;develop&lt;/code&gt; you chose dev or stage, from &lt;code&gt;master&lt;/code&gt; the same plus prod.&lt;/p&gt;

&lt;p&gt;For every release you had to remember &lt;strong&gt;which button to press and for which version&lt;/strong&gt;. Worse, the deploy buttons became active only after &lt;code&gt;version&lt;/code&gt;, &lt;code&gt;build&lt;/code&gt; and &lt;code&gt;pre-deploy&lt;/code&gt; had passed. The code is already merged, and you still have to open the pipeline, wait for the green stages and not forget to press. In effect, sit and refresh the page.&lt;/p&gt;

&lt;p&gt;I replaced that with versioned branches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dev/1.2.1
stage/1.2.1
prod/1.2.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The stage and the version are now in the name, so the pipeline runs only the actions that belong to that stage.&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%2Fv4xk3eaeyy00kwd4l51h.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%2Fv4xk3eaeyy00kwd4l51h.png" alt="Before: shared develop and master branches with manual deploys. After: dev/1.2.1, stage/1.2.1 and prod/1.2.1, where dev and stage deploy automatically and prod is started manually" width="800" height="418"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;GitLab has long been able to run jobs by branch name. What changed is that &lt;strong&gt;the branch now describes not just where the code lives, but where a specific release is in the process&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check that replaced my review comments
&lt;/h2&gt;

&lt;p&gt;I used to leave comments in reviews, and the MR stayed blocked until they were fixed. Then I wrote &lt;code&gt;jira:check&lt;/code&gt;, a Python script that runs in GitLab CI for every merge request. It checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the Jira task key can be extracted from the name of a feature/bugfix branch;&lt;/li&gt;
&lt;li&gt;the target branch has a valid stage prefix;&lt;/li&gt;
&lt;li&gt;the task is in the required status and has the agreed fields filled in: tech stack, component and Fix Version;&lt;/li&gt;
&lt;li&gt;the version in Jira matches the version number of the target branch;&lt;/li&gt;
&lt;li&gt;the MR title follows the naming convention.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Errors are collected into a report, and the job fails if anything is wrong. With "pipelines must succeed" enabled, such an MR cannot be merged until the data is fixed. An MR can be created whenever it is convenient; it just cannot be merged.&lt;/p&gt;

&lt;p&gt;This is a real log of a failed check (translated from the original Russian output, task key removed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Checking target git branch: develop/1.66.0
OK
Checking field "Status"
Error: field "Status" must be "CodeReview", current value: "In development"
Checking version match: 1.65.0 -&amp;gt; 1.66.0
Error: the task is marked for version "1.65.0" but the MR targets a branch of another version: "1.66.0"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv5hzd60pfzo7x30uz7kw.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%2Fv5hzd60pfzo7x30uz7kw.png" alt="A contract between delivery and development: the Jira task has version 1.65.0, the MR targets a stage/version branch, jira:check compares the versions, and on a mismatch the MR can be created but not merged" width="800" height="495"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This only works because the team had agreed on the rules before any script existed: product components, tasks assigned to versions in advance, branch names and the order of MR handling. If the Fix Version is not set, there is nothing to compare. Planning stops being an optional note in a chat and becomes input data for development.&lt;/p&gt;

&lt;h3&gt;
  
  
  Protected branches
&lt;/h3&gt;

&lt;p&gt;The release branches themselves are protected with GitLab Protected Branches, set by masks (&lt;code&gt;develop/*&lt;/code&gt;, &lt;code&gt;alpha/*&lt;/code&gt;, &lt;code&gt;beta/*&lt;/code&gt;, &lt;code&gt;prod/*&lt;/code&gt;), so a new release such as &lt;code&gt;alpha/1.66.0&lt;/code&gt; immediately falls under the right restrictions.&lt;/p&gt;

&lt;p&gt;In the project I describe, merging into &lt;code&gt;develop/*&lt;/code&gt;, &lt;code&gt;alpha/*&lt;/code&gt; and &lt;code&gt;beta/*&lt;/code&gt; through a merge request is open to developers and Maintainers; direct push is for Maintainers only. For &lt;code&gt;prod/*&lt;/code&gt; both merge and push are left to Maintainers. Force push is disabled everywhere.&lt;/p&gt;

&lt;p&gt;I have seen processes where a release was triggered by putting a tag on an arbitrary commit. In the projects I saw, access to such tags was not restricted properly. That is not a flaw of tags as a tool, since GitLab can protect tags too. But in my process &lt;strong&gt;the release event is a merge into the protected branch of the right stage&lt;/strong&gt;, and who may merge is determined by GitLab's role model.&lt;/p&gt;

&lt;h2&gt;
  
  
  The branch name selects the build and the environment
&lt;/h2&gt;

&lt;p&gt;Since a branch is named &lt;code&gt;&amp;lt;stage&amp;gt;/&amp;lt;version&amp;gt;&lt;/code&gt;, GitLab can determine from it what to build and where to send the result. In the Android project the branch selects the build variant (&lt;code&gt;flavor&lt;/code&gt;), the API the app works with, and the environment of the internal release service the APK is uploaded to. Nothing is chosen by hand.&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%2Fo4kqxzss0mz1mqk74teq.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%2Fo4kqxzss0mz1mqk74teq.png" alt="Four lanes for Android: develop gives the Dev build, API dev and the DEV environment; alpha gives Alpha, API test and STAGE; beta gives Beta, API prod and PROD; prod gives Prod, API prod and PROD" width="800" height="458"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The number of stages is a property of the product, not a universal law: the backend project uses &lt;code&gt;develop → test → stage → prod&lt;/code&gt;, and another product built for both web and Android uses &lt;code&gt;develop → stage → prod&lt;/code&gt;. In every case the stage and the version in the branch name give CI/CD a clear event.&lt;/p&gt;

&lt;h2&gt;
  
  
  One merge triggers the routine
&lt;/h2&gt;

&lt;p&gt;What usually happens after a merge where the process lives in documentation? Someone builds the right configuration, publishes the build, finds the list of changes, tells the tester, gives them a link, moves the task to QA and assigns it.&lt;/p&gt;

&lt;p&gt;I bound these actions to a single event, &lt;strong&gt;a successful merge of an MR into a stage branch&lt;/strong&gt;. After it CI:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;builds the right variant of the application or service;&lt;/li&gt;
&lt;li&gt;publishes the artifact to the corresponding environment;&lt;/li&gt;
&lt;li&gt;forms the list of tasks for the release notes;&lt;/li&gt;
&lt;li&gt;sends a notification with a link to the build and the list of changes;&lt;/li&gt;
&lt;li&gt;starts the Jira update: moves the task to "Ready for testing" and assigns QA.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqtc3g0dwkig53c973gxi.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%2Fqtc3g0dwkig53c973gxi.png" alt="After an MR is merged into alpha/1.64.0 the pipeline runs build, deploy, notify and jira-update in order; notify and jira-update run with allow_failure; the tester gets a link, release notes and an assigned task" width="800" height="445"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The release notes script takes the changes brought in by the merge commit, extracts Jira keys from the commit messages and builds a list of tasks. It is not a universal analyzer of Git history: the result depends on the commit format and on changes reaching the branch through a merge commit. That is why the naming convention matters: the automatic result depends on it.&lt;/p&gt;

&lt;p&gt;Notifications used to arrive in Telegram, now it is Mattermost. The tester does not need to ask where to find the build.&lt;/p&gt;

&lt;p&gt;This chain is not magic either. In the current configuration &lt;code&gt;jira:update&lt;/code&gt; and the notification run with &lt;code&gt;allow_failure&lt;/code&gt;: if the Jira operation or sending the message fails, the pipeline may still finish successfully. That has to be watched in the logs and handled separately. I don't want to pass a set of scripts off as a system in which every state is guaranteed to be consistent across all services.&lt;/p&gt;

&lt;h2&gt;
  
  
  The version has to reach the user
&lt;/h2&gt;

&lt;p&gt;Whenever someone reports "the screen doesn't work" or "the server responds wrongly after the update", my first question is: which version?&lt;/p&gt;

&lt;p&gt;In the Android project &lt;code&gt;versionName&lt;/code&gt; is built from the release number taken from the branch name: for &lt;code&gt;alpha/1.64.0&lt;/code&gt; it is &lt;code&gt;1.64.0&lt;/code&gt;, and the stage is shown next to it. In the backend project the version is written to a resource at build time and returned in the &lt;code&gt;Version&lt;/code&gt; response header. A report then contains a release number and a stage instead of "the latest build".&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did not hand over to automation
&lt;/h2&gt;

&lt;p&gt;People decide on moving between stages. In my process, releasing from &lt;code&gt;develop&lt;/code&gt; to the first distribution stage is my responsibility as development lead. After internal testing, QA decides on the next step. Then delivery joins the decision about production, taking into account the beta test results and metrics. A promotion is formalized as a task and an MR; GitLab performs the permitted operation and its technical consequences.&lt;/p&gt;

&lt;p&gt;Some checks CI still does not cover:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it compares the version of the MR, but does not work out whether the release is currently at the right stage;&lt;/li&gt;
&lt;li&gt;it does not check the source branch of the feature; the final scope of a change remains a matter for code review;&lt;/li&gt;
&lt;li&gt;new secrets and variables of the next environment still have to be prepared by a person: at a transition I check CI variables and Vault. Without that, even a successful deployment can end with a system that does not work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And no script replaces the manager who has to decide the contents of a release in advance.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would reuse
&lt;/h2&gt;

&lt;p&gt;The scripts did not teach everyone to understand the process in the same way, and they certainly did not make managers plan better. What they changed is that formal rules stopped depending on my constant personal checking:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Put the stage and the version in the branch name.&lt;/strong&gt; Then CI has an unambiguous event instead of a button.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare the planned version in the tracker with the target branch in the MR check.&lt;/strong&gt; It turns planning into input data for development.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bind routine post-merge actions to the merge event&lt;/strong&gt;, and mark which of them are allowed to fail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Protect release branches by mask&lt;/strong&gt;, so new releases are covered automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the decisions on stage transitions with people&lt;/strong&gt;, and say so explicitly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full case study and supporting artifacts are available on my site: &lt;a href="https://hram.github.io/en/articles/hram-git-flow-ci/" rel="noopener noreferrer"&gt;https://hram.github.io/en/articles/hram-git-flow-ci/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>gitlab</category>
      <category>cicd</category>
      <category>git</category>
      <category>devops</category>
    </item>
    <item>
      <title>GitFlow as cement between planning and development</title>
      <dc:creator>Evgeny Khramov</dc:creator>
      <pubDate>Sat, 10 Oct 2026 14:55:20 +0000</pubDate>
      <link>https://dev.to/hram/branches-as-a-11-mirror-of-the-delivery-plan-the-git-flow-variant-that-replaced-cherry-pick-for-me-anc</link>
      <guid>https://dev.to/hram/branches-as-a-11-mirror-of-the-delivery-plan-the-git-flow-variant-that-replaced-cherry-pick-for-me-anc</guid>
      <description>&lt;p&gt;"We're not shipping this feature yet. Take it out of the release." "And this one needs to go into the previous version, urgently."&lt;/p&gt;

&lt;p&gt;At some point requests like these become routine. You find the commits, run &lt;code&gt;cherry-pick&lt;/code&gt; or &lt;code&gt;revert&lt;/code&gt;, check the dependencies, work out what you touched by accident, and reassemble the release scope.&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%2Fz443hjpp6kxa1mwvbn1i.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%2Fz443hjpp6kxa1mwvbn1i.png" alt="Two bricks, planning and development: on the left the joint between them is patched by a developer with cherry-pick and revert, on the right it is held together by GitFlow" width="800" height="390"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Git commands are not the problem. What bothered me was a different question: why is it the developer who, by default, absorbs the consequences of decisions about release scope and timing? When the release process is loosely organized, that responsibility drifts to whoever knows Git.&lt;/p&gt;

&lt;p&gt;For more than ten years, across different projects and companies, I have used my own variant of GitFlow built to prevent that. This is how it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The state of a project at any moment
&lt;/h2&gt;

&lt;p&gt;Here is what the branches look like in my model on an ordinary day:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Version   Current stage
1.27.0    develop
1.26.0    develop
1.25.0    alpha
1.24.0    beta
1.23.1    prod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not an emergency. It is the pipeline working normally. In real work three or four versions are alive at once: one on beta, the next in internal testing, one more in active development. Sometimes functionality is ready one or two releases ahead.&lt;/p&gt;

&lt;p&gt;Each version is its own chain of branches, one branch per stage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;develop/1.25.0 → alpha/1.25.0 → beta/1.25.0 → prod/1.25.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are not four shared branches for the whole project. They are four stages of one specific version. The next version has its own chain: &lt;code&gt;develop/1.26.0 → alpha/1.26.0 → …&lt;/code&gt;. The stage names depend on the product; &lt;code&gt;test → stage → prod&lt;/code&gt; works just as well as &lt;code&gt;alpha → beta → prod&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%2Fh52f7lh2b7kmks6hnw8v.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%2Fh52f7lh2b7kmks6hnw8v.png" alt="From stages to Git branches: develop, alpha, beta and prod become develop/1.25.0, alpha/1.25.0, beta/1.25.0 and prod/1.25.0; a branch name is a stage plus a version" width="800" height="370"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo8fqsuokv5dfgfaulnik.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%2Fo8fqsuokv5dfgfaulnik.png" alt="A matrix of versions and stages: stages develop, alpha, beta, prod horizontally, releases from newer to older vertically; each version has its current stage highlighted" width="800" height="410"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules
&lt;/h2&gt;

&lt;p&gt;Three rules produce this picture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A release is promoted as a whole.&lt;/strong&gt; Management plans the sequence of releases and their scope; what you call that role (product, release manager, delivery, team lead) matters less than the responsibility not vanishing between job titles. When a release moves from one stage to the next, we carry over its state instead of selecting tasks from a list. Scope can still be discussed and changed, but the normal place for that is planning, not the moment the code is already assembled.&lt;/p&gt;

&lt;p&gt;The handoff from development to the first distribution stage is a signature from the development lead: everything planned is implemented, covered by tests, and verified by the team. The safety net for work that did not make it is feature flags. All new functionality is developed behind them, so an unfinished feature can travel to production switched off. There is no need to cut it out with &lt;code&gt;revert&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A new version branches off the current stage of the newest version.&lt;/strong&gt; If the newest version is &lt;code&gt;1.24.0&lt;/code&gt; and it is on &lt;code&gt;alpha&lt;/code&gt;, then &lt;code&gt;develop/1.25.0&lt;/code&gt; starts from &lt;code&gt;alpha/1.24.0&lt;/code&gt;. If the newest is &lt;code&gt;1.65.0&lt;/code&gt; on &lt;code&gt;beta&lt;/code&gt;, then &lt;code&gt;develop/1.66.0&lt;/code&gt; starts from &lt;code&gt;beta/1.65.0&lt;/code&gt;. You do not wait for &lt;code&gt;prod/1.24.0&lt;/code&gt;, and there is no single eternal &lt;code&gt;develop&lt;/code&gt; where future releases get mixed together.&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%2Fxh80uunkrim9gkstnpjk.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%2Fxh80uunkrim9gkstnpjk.png" alt="The new version 1.25.0 is planned and branches off from alpha, the current stage of the newest version 1.24.0; there is no need to wait for prod" width="800" height="335"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Fixes move forward.&lt;/strong&gt; A fix made in an older version must end up in all newer versions that have already branched off from it. Explicitly, through a merge request.&lt;/p&gt;

&lt;p&gt;I call the smaller version number "older" and the larger one "newer": &lt;code&gt;1.24.0&lt;/code&gt; is older than &lt;code&gt;1.25.0&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is not classic Git Flow
&lt;/h2&gt;

&lt;p&gt;Classic Git Flow (Vincent Driessen's model) has one permanent &lt;code&gt;develop&lt;/code&gt; branch. It is the branch of the next release: features are merged into it once it is decided they go into the nearest release, and a release branch is cut from it for stabilization. With one release at a time it works well, and I do not consider it a bad scheme.&lt;/p&gt;

&lt;p&gt;The questions start when the plan is longer than one release. There is no room for release N+2. Its features either sit in feature branches waiting for the previous release branch to be cut, or they get merged into the shared &lt;code&gt;develop&lt;/code&gt; all mixed together. I have seen both many times. In the first case the branches fall behind and accumulate conflicts. In the second, &lt;code&gt;develop&lt;/code&gt; stops reflecting anything definite, and a feature that ends up not shipping has to be reverted. That is not a defect of the model; it is a consequence of having room for exactly one future release.&lt;/p&gt;

&lt;p&gt;I do it the other way around. The product already has a delivery plan: which releases, in what order, at what stage. It lives in the tracker (Jira, in my case). I do not invent a separate scheme for Git; I reflect the plan in branches one to one. Each release has its own chain, each stage has its own branch, and the stage on the board determines which branch is current.&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%2F0pcf9wxhc94wdmg0qtd8.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%2F0pcf9wxhc94wdmg0qtd8.png" alt="Top: the delivery plan in Jira, with release cards 1.27.0, 1.26.0, 1.25.0, 1.24.0 and 1.23.1 in stage columns. Bottom: GitLab, with the current-stage branch of each release in the same columns. A one-to-one match" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Practical consequences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The next release does not wait. As soon as it appears in the plan it has its own &lt;code&gt;develop/&amp;lt;version&amp;gt;&lt;/code&gt;, and developers merge into it right away.&lt;/li&gt;
&lt;li&gt;The scope is visible from the branch: &lt;code&gt;develop/1.25.0&lt;/code&gt; contains what was planned for &lt;code&gt;1.25.0&lt;/code&gt;, not everything that happened to get merged.&lt;/li&gt;
&lt;li&gt;"What do we revert?" comes up rarely, because scope is defined in advance and unfinished work sits behind a flag.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A mirror does not make the plan better. If the plan is chaotic, the branches show the same chaos. The difference is that it is visible in the branches immediately, instead of hiding in a queue of feature branches waiting for their release.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scenario: 1.24.0 is in testing, 1.25.0 is already in development
&lt;/h2&gt;

&lt;p&gt;This is a real situation from my project. We had created &lt;code&gt;develop/1.25.0&lt;/code&gt; and were working on new tasks while &lt;code&gt;1.24.0&lt;/code&gt; was still at &lt;code&gt;alpha&lt;/code&gt;. Testers found a bug.&lt;/p&gt;

&lt;p&gt;Stopping &lt;code&gt;1.25.0&lt;/code&gt; until &lt;code&gt;1.24.0&lt;/code&gt; reaches production is bad. Fixing the defect only in the new version and leaving the old one in an undefined state is also bad.&lt;/p&gt;

&lt;p&gt;The bug is fixed where it was found: in &lt;code&gt;alpha/1.24.0&lt;/code&gt;, the same stage of the same release. QA keeps checking that release. Development of &lt;code&gt;1.25.0&lt;/code&gt; does not stop.&lt;/p&gt;

&lt;p&gt;In our scenario, once testing of &lt;code&gt;1.24.0&lt;/code&gt; is complete and it moves to the next stage, we carry the accumulated fixes, via a merge request, into the current-stage branch of &lt;code&gt;1.25.0&lt;/code&gt;. Not one &lt;code&gt;cherry-pick&lt;/code&gt; at a time, but by merging the older release's changes into the newer one.&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%2Fyoh9ahemg9rf8n1ns9u6.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%2Fyoh9ahemg9rf8n1ns9u6.png" alt="Release 1.25.0 is developed on develop, release 1.24.0 is fixed on alpha; after the checks 1.24.0 moves to beta, and the fixes are carried along the dashed arrow, via a merge request, into the current-stage branch of 1.25.0" width="800" height="380"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two different movements of changes happen here and must not be confused. The first is horizontal: &lt;code&gt;1.24.0&lt;/code&gt; goes through its stages, for example &lt;code&gt;alpha → beta → prod&lt;/code&gt;. The second is between versions: changes made in &lt;code&gt;1.24.0&lt;/code&gt; after the branch-off must end up in &lt;code&gt;1.25.0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Note the order: we do not drag every fix into the new version the moment it appears. First the checking of the older release is completed. Other projects may have different checkpoints, but the requirement is the same: fixes must not get lost.&lt;/p&gt;

&lt;p&gt;Conflicts do happen in such a merge, but as a rule they are simple. They occur in shared files where different releases each append a line: string resources, the list of feature flags, analytics events. Two new lines, one under the other, simply both stay.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hotfixes
&lt;/h3&gt;

&lt;p&gt;Where is the line between "fix it at the current stage" and "ship a hotfix"? For us it is &lt;code&gt;beta&lt;/code&gt;. Beta is already a rollout to a test group of users, almost a release. A bug found on &lt;code&gt;alpha&lt;/code&gt; is fixed right there and the release moves on. A bug found in &lt;code&gt;beta/1.24.0&lt;/code&gt; is a hotfix: the fix goes into a new patch version &lt;code&gt;1.24.1&lt;/code&gt;, which continues from beta.&lt;/p&gt;

&lt;p&gt;The same applies to production. If a problem is found in &lt;code&gt;prod/1.62.0&lt;/code&gt; that cannot wait for the next regular release, a patch version &lt;code&gt;1.62.1&lt;/code&gt; is created from it and goes through its own cycle of checks. After the release, the fix is carried into the newer releases, for example into the current stage of &lt;code&gt;1.63.*&lt;/code&gt;, and further if even newer versions have already branched off.&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%2Fwxidxntmojiwj1sck49m.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%2Fwxidxntmojiwj1sck49m.png" alt="A bug is found in prod/1.62.0, patch 1.62.1 goes through its own cycle and reaches prod, after which the fix is carried along the dashed arrow into alpha, the current stage of release 1.63.0, and further" width="800" height="410"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  cherry-pick and revert: the numbers, and what the model costs
&lt;/h2&gt;

&lt;p&gt;Over the last five years, as far as I remember, we used &lt;code&gt;cherry-pick&lt;/code&gt; and &lt;code&gt;revert&lt;/code&gt; to change a release's scope once or twice. The business really wanted a feature moved into an earlier release, and we went along with it. That figure is from memory, not from a count, but it is the normal difference between a rule and an exception: sometimes a request is worth stepping outside the process.&lt;/p&gt;

&lt;p&gt;The problem starts when the exception becomes the way you ship. Once selective transfer is a standard procedure, planning gets a cheap workaround: "let's build everything first and decide what to ship later." As long as there is always a developer with &lt;code&gt;cherry-pick&lt;/code&gt; at hand, the process has no reason to change.&lt;/p&gt;

&lt;p&gt;Responsibility is split like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;management&lt;/strong&gt; defines the scope and sequence of releases;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the development lead&lt;/strong&gt; is responsible for the version being ready to hand over to distribution;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;QA and delivery&lt;/strong&gt; are responsible for the checks and for the decisions to promote through the stages;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;developers&lt;/strong&gt; implement the tasks, fix defects, and resolve conflicts when changes are carried between versions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The costs are real. You run several versions in parallel, you must remember to merge fixes from older releases into newer ones, and you have to agree on checkpoints and rare exceptions. In a team where release scope is, as a matter of principle, decided at the last moment and selective delivery of changes is the core business model, this process will be inconvenient.&lt;/p&gt;

&lt;p&gt;I am not claiming this is a universal recipe. It is the process I have carried from project to project for more than ten years, and it works for me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;A product has two kinds of work: release planning and development. Different people do them, and both are needed. If there is no reliable joint between them, it gets patched by hand with "take the feature out" requests, &lt;code&gt;cherry-pick&lt;/code&gt;s and &lt;code&gt;revert&lt;/code&gt;s, and the developers pay for it.&lt;/p&gt;

&lt;p&gt;The branches here are not an invented scheme but the delivery plan in explicit form. Branch names and merge rules only make the agreement about responsibility visible. It is that agreement the joint rests on.&lt;/p&gt;

&lt;p&gt;I keep the full text, with the hero illustration and the complete set of diagrams, on my site: &lt;a href="https://hram.github.io/en/articles/hram-git-flow-base/" rel="noopener noreferrer"&gt;https://hram.github.io/en/articles/hram-git-flow-base/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>git</category>
      <category>workflow</category>
      <category>softwareengineering</category>
      <category>management</category>
    </item>
    <item>
      <title>My OCR Pipeline Asked for 44 GB of RAM to Read Three Invoice Columns. The Fix Wasn't a Better Model.</title>
      <dc:creator>Evgeny Khramov</dc:creator>
      <pubDate>Sat, 03 Oct 2026 12:06:49 +0000</pubDate>
      <link>https://dev.to/hram/my-ocr-pipeline-asked-for-44-gb-of-ram-to-read-three-invoice-columns-the-fix-wasnt-a-better-model-38j</link>
      <guid>https://dev.to/hram/my-ocr-pipeline-asked-for-44-gb-of-ram-to-read-three-invoice-columns-the-fix-wasnt-a-better-model-38j</guid>
      <description>&lt;p&gt;The task was narrow. Take a phone photo of an UPD (a standard Russian invoice form), read the VAT rate from column 7, group the rows by rate, sum columns 8 and 9, and check the sums against the document total.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;photo → find three columns → read the numbers → group by VAT rate → check the sums
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My wife is an accountant and does this by hand, so I started building a tool for it with a coding agent. I assumed OCR would be the hard part. It turned out to be one of the easiest.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first version: give the whole page to PaddleOCR
&lt;/h2&gt;

&lt;p&gt;My own spec asked for the obvious architecture: OpenCV preprocessing, then PaddleOCR's table recognition as the main path, with classical geometry only as a fallback.&lt;/p&gt;

&lt;p&gt;The agent built it: page detection, perspective warp, deskew, CLAHE, then two full-page passes — &lt;code&gt;TableRecognitionPipelineV2&lt;/code&gt; for structure and a separate &lt;code&gt;PaddleOCR(lang="ru")&lt;/code&gt; for text.&lt;/p&gt;

&lt;p&gt;The first real phone photo, about 5 MB, made PaddleOCR try to allocate roughly 44 GB and the request died with &lt;code&gt;ResourceExhaustedError&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The obvious hypothesis was "the photo is too big", so the agent capped the long side at 2600 px. A large synthetic image now completed — in about 44 seconds, peaking near 10 GB RSS. It looked like a fix.&lt;/p&gt;

&lt;p&gt;Then the same class of failure came back on another real document.&lt;/p&gt;

&lt;h2&gt;
  
  
  The image size was a symptom
&lt;/h2&gt;

&lt;p&gt;From that point the agent ran every experiment under &lt;code&gt;systemd-run&lt;/code&gt; with a hard &lt;code&gt;MemoryMax&lt;/code&gt; and swap disabled, so a bad hypothesis would kill one process instead of freezing the laptop.&lt;/p&gt;

&lt;p&gt;The first reproducible run under a 14 GB limit:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;input image&lt;/td&gt;
&lt;td&gt;1462×2600&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;model loading&lt;/td&gt;
&lt;td&gt;~85 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RSS after loading&lt;/td&gt;
&lt;td&gt;~1.97 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;first full-page inference&lt;/td&gt;
&lt;td&gt;over 14 GB, process killed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Even the downscaled image didn't fit. The cause was the composition of the pipeline: &lt;code&gt;PaddleOCR(ru)&lt;/code&gt; ran its own text detector over the whole page, and &lt;code&gt;TableRecognitionPipelineV2&lt;/code&gt; carried its own models for layout, OCR, and table structure. The page went through several heavy models, with part of the work duplicated.&lt;/p&gt;

&lt;p&gt;PaddleOCR wasn't the problem. Feeding it a whole page to get three columns was.&lt;/p&gt;

&lt;p&gt;The new scheme:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ORIGINAL → small working copy → classical CV: geometry → coordinates
ORIGINAL → small crops at those coordinates → text recognition model only
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Working image for geometry, original for reading.&lt;/strong&gt; With recognition running only on crops, peak memory in a control run was about 627 MB.&lt;/p&gt;

&lt;p&gt;But that only works if the geometry is right. And the standard way of getting it right made things worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Aligning by the sheet edge was worse than not aligning at all
&lt;/h2&gt;

&lt;p&gt;The first geometric version did what a document scanner does: find the sheet contour, warp it to a rectangle. But the sheet often didn't fit in the frame, and the paper edge isn't necessarily parallel to the printed table.&lt;/p&gt;

&lt;p&gt;Morphology then found zero or one long horizontal line instead of about thirty. Rotating by the angle of the horizontals fixed them and tilted the verticals by about 0.31° — so the distortion was shear or perspective, not rotation. A shear correction helped.&lt;/p&gt;

&lt;p&gt;At that point I asked the agent why step two was fixing a tilt that step one should have removed. It proposed moving the tilt measurement into step one — tidier, but the reference object was still the sheet of paper.&lt;/p&gt;

&lt;p&gt;Years ago I wrote a &lt;a href="https://github.com/hram/Sudoku" rel="noopener noreferrer"&gt;Sudoku recognizer&lt;/a&gt;, and I told the agent how it worked: look for near-vertical and near-horizontal lines on the uncropped frame within a range of angles, find their intersections, and derive the transform from those.&lt;/p&gt;

&lt;p&gt;The useful part wasn't the code, it was the choice of reference. If I need the table's geometry, the table is what should be aligned.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;source frame → LSD line segments → horizontal/vertical lines
→ intersections = table nodes → homography (RANSAC) → one warp of the original
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Sudoku grid is regular, so every node's ideal position is known. An invoice has columns and rows of different sizes, so the agent did a rough transform from the outer geometry and refined it over many nodes with outlier rejection. Debugging took several iterations: LSD found both edges of a thick line, and digits in a narrow column produced false segments.&lt;/p&gt;

&lt;p&gt;Then all variants were measured with one metric:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variant&lt;/th&gt;
&lt;th&gt;Tilt, horizontals / verticals&lt;/th&gt;
&lt;th&gt;Mean node residual&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No alignment&lt;/td&gt;
&lt;td&gt;0.032° / 0.053°&lt;/td&gt;
&lt;td&gt;0.76 px&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;By sheet contour&lt;/td&gt;
&lt;td&gt;0.374° / 0.010°&lt;/td&gt;
&lt;td&gt;1.97 px&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contour + shear&lt;/td&gt;
&lt;td&gt;0.105° / 0.004°&lt;/td&gt;
&lt;td&gt;0.98 px&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;By table grid&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.010° / 0.002°&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.26 px&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Look at the second row. Aligning by the sheet contour was quantitatively worse than doing nothing: the tilt we kept correcting was partly introduced by our own preprocessing. The grid-based method cost about 0.8 s more per page at that stage.&lt;/p&gt;

&lt;h2&gt;
  
  
  A one-pixel error on the working copy clips a digit on the original
&lt;/h2&gt;

&lt;p&gt;With the grid found on the reduced copy, I scaled the coordinates back and cut cells from the original. Some money values lost their last digit: one pixel on the working copy is several on the original, and morphology widens the table lines further.&lt;/p&gt;

&lt;p&gt;The fix was a second coarse-to-fine level. The working-image grid gives an approximate boundary; then, in the original, the algorithm searches a ±10 px window for the actual printed line and crops from there.&lt;/p&gt;

&lt;p&gt;The control metric was &lt;code&gt;edge_ink&lt;/code&gt; — crops with ink touching the edge. It went from 67 of 84 to 3 of 84, and those three were specks and pen marks, not clipped digits.&lt;/p&gt;

&lt;h2&gt;
  
  
  The biggest accuracy gain was a crop, not a model
&lt;/h2&gt;

&lt;p&gt;With detection and table recognition gone, one small text recognition model remained. Two findings from comparing inputs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Binarization helped geometry and hurt recognition. The model read grayscale better.&lt;/li&gt;
&lt;li&gt;A strip of several columns scored slightly higher than separate cells at first, but I kept separate cells: one lost comma in a strip breaks the parsing of its neighbors, while a cell keeps its own bbox, crop, and result as evidence.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The remaining errors clustered in double-height rows. There the value occupies a small part of a tall cell, and the model resizes the whole crop to a fixed 48 px height, so the digits shrink.&lt;/p&gt;

&lt;p&gt;Adding a tight crop around the connected ink components, on a hand-labeled golden document:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;separate cells: 76 → 82 out of 83
strip:          79 → 83 out of 83
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enabling MKL-DNN for the recognition model, on the same tight crops, cut a single call from about 155 to 44 ms for cells and from 366 to 93 ms for the strip.&lt;/p&gt;

&lt;p&gt;The tight crop brought its own errors — dots, pen marks, and grid-line remnants stretching the box — and the component rules had to be tightened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Constrain the decoder by what the field can contain
&lt;/h2&gt;

&lt;p&gt;One error remained: a smudged &lt;code&gt;10%&lt;/code&gt; read as &lt;code&gt;1Q%&lt;/code&gt;. A &lt;code&gt;Q → 0&lt;/code&gt; rule would encode one photo's defect. But I know the field's semantics: a VAT rate is digits, a percent sign, or the text «без НДС» ("no VAT"); a money amount is digits and separators.&lt;/p&gt;

&lt;p&gt;The restriction went into CTC decoding as a per-field character whitelist. That gave 83 of 83 on the golden document.&lt;/p&gt;

&lt;p&gt;It can't be global. The column-number row contains labels like &lt;code&gt;1а&lt;/code&gt; and &lt;code&gt;10а&lt;/code&gt;, and a numeric whitelist corrupts them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Perfect characters, a row that never existed
&lt;/h2&gt;

&lt;p&gt;A second form, TORG-12 (another Russian delivery note), arrived rotated and on curved paper.&lt;/p&gt;

&lt;p&gt;Orientation: PaddleOCR's classifier on the central crop got 1 of 24 artificial rotations wrong, because the page center held signatures instead of text. Voting over a 3×3 grid of regions, skipping empty ones, gave 24 of 24. Document type came from the already-read column-number row, with &lt;code&gt;UNKNOWN&lt;/code&gt; returned when features conflict.&lt;/p&gt;

&lt;p&gt;The dangerous problem was structural. Global row detection found 16 rows instead of 18: the curvature merged two pairs of neighbors. Since columns are read independently, the pipeline could take an amount from one physical row and the VAT from the next and emit a record that was never on the paper — with every character read correctly.&lt;/p&gt;

&lt;p&gt;The fix was to stop solving the whole page again: search for row boundaries only inside the narrow strip of needed columns, where curvature barely shows. That found all 18 rows, with no mixed values on this document.&lt;/p&gt;

&lt;h2&gt;
  
  
  Speed: a dictionary beat a heavier model
&lt;/h2&gt;

&lt;p&gt;By now a document took 4.4–4.8 s, 75–80% of it in the recognition network. The agent compared seven recognizers (several PaddleOCR generations and Tesseract) in two modes on two documents.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;PP-OCRv6_tiny&lt;/code&gt; was fastest, but confused &lt;code&gt;6&lt;/code&gt; and &lt;code&gt;8&lt;/code&gt; and broke the column-number row.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;en_PP-OCRv3_mobile_rec&lt;/code&gt; was about 1.8–2.6× faster than the original model, with one systematic error: the smudged &lt;code&gt;10%&lt;/code&gt; became &lt;code&gt;19%&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A heavier model was one option. But the set of legal rates is finite: &lt;code&gt;0%&lt;/code&gt;, &lt;code&gt;5%&lt;/code&gt;, &lt;code&gt;7%&lt;/code&gt;, &lt;code&gt;10%&lt;/code&gt;, &lt;code&gt;20%&lt;/code&gt;, &lt;code&gt;22%&lt;/code&gt;, «без НДС». So for this field the decoder computes the CTC probability of each legal entry and picks the most probable. That is more than a character whitelist: the decoder knows the full dictionary of values. On the smudge, &lt;code&gt;10%&lt;/code&gt; wins.&lt;/p&gt;

&lt;p&gt;Result with &lt;code&gt;en_PP-OCRv3_mobile_rec&lt;/code&gt; as the default: 135 of 135 labeled values on the two-document collection, and 4.84 → 2.81 s on the TORG-12, 6.06 → 3.19 s on the UPD.&lt;/p&gt;

&lt;p&gt;That is two documents, not an accuracy claim. And the fastest hybrid, with the tiny model, was rejected because it lost one real value.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the agent did and what I did
&lt;/h2&gt;

&lt;p&gt;The first full-page pipeline matched my own spec — I asked for it. After the crashes we switched to an experiment log: hypothesis before a change, measurements and artifacts after. The agent ran variants under the memory limit, measured time and RSS, saved crops and overlays, and moved the winners into the main pipeline.&lt;/p&gt;

&lt;p&gt;The agent found the real cause of the OOM, after the resize fix failed. I changed the direction where the model of the task had to change: the Sudoku episode replaced the paper edge with the table grid as the reference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this stands
&lt;/h2&gt;

&lt;p&gt;It started as &lt;code&gt;image → OCR → data&lt;/code&gt;. It is now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;image → geometry → physical rows and cells → OCR
→ domain constraints → structured data → validation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The neural network sits in the narrowest spot, reading a small fragment that is already located and cropped. Almost none of the big gains came from a bigger model: memory dropped because the network stopped seeing the page, geometry improved because the reference became the table, accuracy improved because the crop got tighter and the decoder learned the field's semantics.&lt;/p&gt;

&lt;p&gt;PaperLedger is not a finished product. My wife doesn't use it in her daily work; I run experiments on her real scenarios and show her the results, and real use would need her company's approval because of the data in the documents.&lt;/p&gt;

&lt;p&gt;The full case study is on my site: &lt;a href="https://hram.github.io/en/articles/paper-ledger/" rel="noopener noreferrer"&gt;https://hram.github.io/en/articles/paper-ledger/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>opencv</category>
      <category>ocr</category>
      <category>ai</category>
    </item>
    <item>
      <title>A Coding Agent Added Jetpack Compose Support to My Android Renderer MCP. I Wouldn't Have Started This Task Myself.</title>
      <dc:creator>Evgeny Khramov</dc:creator>
      <pubDate>Fri, 02 Oct 2026 14:36:21 +0000</pubDate>
      <link>https://dev.to/hram/a-coding-agent-added-jetpack-compose-support-to-my-android-renderer-mcp-i-wouldnt-have-started-5b6l</link>
      <guid>https://dev.to/hram/a-coding-agent-added-jetpack-compose-support-to-my-android-renderer-mcp-i-wouldnt-have-started-5b6l</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/hram/android-ui-renderer-mcp" rel="noopener noreferrer"&gt;Android UI Renderer MCP&lt;/a&gt; is a local MCP server that lets a coding agent render Android UI without an emulator or a device: it gets back a PNG plus a component tree it can query for bounds, text, and state.&lt;/p&gt;

&lt;p&gt;It already handled classic Android UI — XML/View, &lt;code&gt;RecyclerView&lt;/code&gt;, Activity + Fragment, overlays, different screen configurations. For XML there's a clear entry point: load the layout resource, apply a fixture, measure, lay out the View hierarchy, draw it, serialize the tree.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;XML layout → inflate → fixture with data → measure/layout → PNG + View tree
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Jetpack Compose has no equivalent entry point. There's no file to &lt;code&gt;inflate&lt;/code&gt;. There's a Kotlin function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Composable&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;BookItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;book&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Book&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;selected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Unit&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rendering it means understanding its signature, building correct Kotlin values for every parameter (enums, nullable types, data classes, collections, callbacks), resolving the app's theme, and only then calling the composable inside an environment where Compose can actually draw. In a real app, a &lt;code&gt;ViewModel&lt;/code&gt;, DI, navigation, and data loading show up right next to it.&lt;/p&gt;

&lt;p&gt;That's not "support one more UI format." That's a separate research problem, and it's exactly the kind of problem I usually never got around to starting — not because it's unsolvable, but because the cost of finding out &lt;em&gt;how&lt;/em&gt; to approach it could easily exceed the value of the feature itself.&lt;/p&gt;

&lt;p&gt;This time I handed that research problem to a coding agent. What follows is what it built, where it broke, and what I had to check before trusting the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  I rejected the agent's first proposal
&lt;/h2&gt;

&lt;p&gt;The safe option was the obvious one: let every Compose project provide its own renderer entry point — a function that already knows how to build the needed state, wire dependencies, and call the composable, with the MCP just invoking that prepared adapter.&lt;/p&gt;

&lt;p&gt;It would have worked. It also would have killed the point of the tool.&lt;/p&gt;

&lt;p&gt;The renderer isn't meant to be another test framework bolted onto a project. It's meant to support a loop an agent runs on an existing project with no manual prep:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;change UI → render → inspect PNG + structure → fix
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a developer has to hand-write an adapter for every screen first, agent autonomy ends exactly where the real UI begins. So instead of asking "how do we render a composable," I set a constraint on the result:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The MCP must adapt to Kotlin/Compose on its own. The project must not have to write a special renderer entry point.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I didn't know how to implement that. I specified a property the result had to have, and left the research and implementation to the agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;render_compose&lt;/code&gt; does
&lt;/h2&gt;

&lt;p&gt;The MCP got a new tool. Instead of an XML layout, the agent passes the fully qualified name of a top-level composable plus named JSON arguments:&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;"function"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"io.github.example.ComposeBookItem"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&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;"selected"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"book"&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;"Designing Data-Intensive Applications"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AVAILABLE"&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;"widthPx"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1280&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"heightPx"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"densityDpi"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;240&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;From there the renderer:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;finds the Kotlin file with the target top-level &lt;code&gt;@Composable&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;parses the function's parameters;&lt;/li&gt;
&lt;li&gt;checks for extra and missing arguments;&lt;/li&gt;
&lt;li&gt;builds typed Kotlin values from the JSON;&lt;/li&gt;
&lt;li&gt;generates an ordinary Kotlin call to the composable;&lt;/li&gt;
&lt;li&gt;substitutes no-op lambdas for missing callbacks;&lt;/li&gt;
&lt;li&gt;finds the project-level &lt;code&gt;AppTheme&lt;/code&gt;, or uses an explicitly passed wrapper;&lt;/li&gt;
&lt;li&gt;creates a temporary Kotlin probe;&lt;/li&gt;
&lt;li&gt;runs it via Gradle/Robolectric;&lt;/li&gt;
&lt;li&gt;draws the &lt;code&gt;ComposeView&lt;/code&gt; to a PNG.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The project's own sources aren't touched — the temporary probe lives in &lt;code&gt;.android-ui-renderer&lt;/code&gt;. Nullable values, enums, data classes, mutable properties, &lt;code&gt;List&lt;/code&gt;, and &lt;code&gt;Set&lt;/code&gt; are all supported, so the agent never has to pre-build a &lt;code&gt;renderForMcp()&lt;/code&gt; function inside the app.&lt;/p&gt;

&lt;p&gt;To verify this wasn't just working on a toy composable, I asked the agent to clone an open-source Jetpack Compose sample project end to end. The repo now has two equivalent apps, &lt;code&gt;sample/&lt;/code&gt; (XML/View) and &lt;code&gt;sample-compose/&lt;/code&gt; (Compose), sharing the same book library and the same seven scenarios: a book row, a long title at &lt;code&gt;fontScale = 1.3&lt;/code&gt;, a list, master-detail, full screen, a loading state, and a Russian dark theme.&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%2Fy1vnjs00be5rrh9e8yue.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%2Fy1vnjs00be5rrh9e8yue.png" alt=" " width="800" height="319"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Same scenario, two different UI stacks, both captured on a 1920×1200 px canvas at 240 dpi. This isn't a pixel-perfect comparison between XML and Compose — it demonstrates that the renderer reproduces equivalent states on the same canvas through two different mechanisms.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first PNG worked. The tool's contract still didn't.
&lt;/h2&gt;

&lt;p&gt;The most useful failure showed up almost immediately. On a real Compose component, the agent got back a normal, correct-looking PNG. By screenshot-tool standards, that's "done."&lt;/p&gt;

&lt;p&gt;But tree serialization broke right after: one of the Compose semantics nodes had no &lt;code&gt;className&lt;/code&gt;, which the renderer's existing protocol expected on every tree node. The image existed; a full MCP response didn't.&lt;/p&gt;

&lt;p&gt;For a plain screenshot tool, that's a rounding error. For an agent tool, it isn't — an agent doesn't just look at a PNG, it needs to ask the renderer concrete questions afterward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;where is this element? what are its bounds? what text does it have?
is it clickable? selected? checked? what's inside it?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;XML answers that through the View tree. For Compose, the renderer now returns a &lt;strong&gt;semantics tree&lt;/strong&gt; instead — &lt;code&gt;bounds&lt;/code&gt;, &lt;code&gt;text&lt;/code&gt;, &lt;code&gt;contentDescription&lt;/code&gt;, &lt;code&gt;enabled&lt;/code&gt;, &lt;code&gt;clickable&lt;/code&gt;, &lt;code&gt;selected&lt;/code&gt;, &lt;code&gt;checked&lt;/code&gt;, and child nodes. The agent added a fallback for semantics nodes without a conventional &lt;code&gt;className&lt;/code&gt;, rebuilt the renderer, and reran the same request. Here's a fragment of the resulting &lt;code&gt;view-tree.json&lt;/code&gt;, with bounds and state the agent can act on next:&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"semantics-33"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"className"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"androidx.compose.ui.semantics.SemanticsNode"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"clickable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bounds"&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;"left"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;650&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"top"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"right"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;799&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"bottom"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;560&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;"children"&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"semantics-35"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Reserve"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"bounds"&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;"left"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;686&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"top"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;515&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"right"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;763&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"bottom"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;545&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;Only after that fix did the tool's actual contract pass — image &lt;em&gt;and&lt;/em&gt; structure, not just a picture an agent has to eyeball.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then a second failure, unrelated to Compose itself
&lt;/h2&gt;

&lt;p&gt;The renderer temporarily injects test dependencies via a Gradle init script. In the new Compose demo project, this hit a strict &lt;code&gt;repositoriesMode&lt;/code&gt;: the project's Gradle settings forbade adding a Robolectric dependency that way.&lt;/p&gt;

&lt;p&gt;So the UI rendering already worked, but the reproducible open demo didn't build. The agent changed &lt;code&gt;sample-compose&lt;/code&gt;'s repositories mode to &lt;code&gt;PREFER_PROJECT&lt;/code&gt; and left a comment explaining why the renderer needs to add a temporary test dependency at all. This is a familiar shape in agentic work: "add Compose support" doesn't stop at the Compose API — the agent has to walk the whole path to a reproducible result, including Gradle policy that has nothing to do with Compose semantics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timeline: under two hours from constraint to working demo
&lt;/h2&gt;

&lt;p&gt;Per the session log: 12:26, first note of the technical problem. 12:41, I rejected the mandatory project-side entry point. ~13:00, first real Compose render — PNG succeeds, semantics tree serialization fails. Then a second successful run, the Gradle policy fix, tests, and all seven scenarios. 14:05, the main commit lands in &lt;code&gt;main&lt;/code&gt;: Compose support, tests, Compose demo. 14:09, a follow-up commit after I flagged that the two comparison images had been captured at different canvas sizes.&lt;/p&gt;

&lt;p&gt;From stating the constraint to a fixed, public result: about &lt;strong&gt;1 hour 43 minutes&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I'm not turning that into "a human would take N days." I don't know how long this would've taken me, because the honest answer is I probably wouldn't have started it — I'd have had to separately learn how to call an arbitrary composable programmatically, how Compose compiler transformations affect that, how to get a semantics tree, how to run all of it on Robolectric, and how to avoid turning the project into a pile of special test entry points. All of that is researchable. The cost of that research was the actual stop factor, and that's what changed: not the speed of writing code, but the economics of deciding whether an idea is worth trying.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the agent did, and what I did
&lt;/h2&gt;

&lt;p&gt;Looking only at the diff, my part looks small. The agent designed and implemented &lt;code&gt;render_compose&lt;/code&gt;, built the typed Kotlin call generator, built the Compose probe and semantics tree, added unit tests for enums/data classes/mutable state/callbacks, verified against a real Compose component, created the open &lt;code&gt;sample-compose&lt;/code&gt; project with seven reproducible scenarios, and fixed both failures above.&lt;/p&gt;

&lt;p&gt;I didn't write that code. What I did do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;rejected the mandatory project-side adapter and set the actual constraint (MCP adapts, project stays a normal project);&lt;/li&gt;
&lt;li&gt;required verification against a real, working component instead of a toy composable;&lt;/li&gt;
&lt;li&gt;caught the canvas-size mismatch in the XML/Compose comparison after the first "success."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's a real shift in where my time goes. The old chain was &lt;em&gt;study the unfamiliar area → find an approach → implement → debug → test → demo → verify&lt;/em&gt;. The new one is &lt;em&gt;state the required property → set constraints → let the agent run research/implementation/tests/demo/fixes → verify it's actually the result I need&lt;/em&gt;. The technical complexity didn't disappear — it's still in the code. I just stopped having to walk the whole research path myself before I even get to decide if the idea is worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits, stated plainly
&lt;/h2&gt;

&lt;p&gt;The current implementation is deliberately narrow. The renderer works with a &lt;strong&gt;top-level composable&lt;/strong&gt;, not a production navigation flow — no DI, no real data loading, visual state passed explicitly as arguments. Callbacks are replaced with no-op lambdas, so there's no simulated user interaction. Kotlin signature parsing is a custom regex-based parser, not the Kotlin compiler API, so complex overloads, type aliases, and unusual annotations may need more work. Compose returns a semantics tree, not an Android View tree, so identifiers are synthetic. And I haven't verified pixel-perfect agreement with a physical device — I'm not claiming that.&lt;/p&gt;

&lt;p&gt;For the task I actually had, this is enough: take a specific visual state, render real Compose UI, get a PNG plus a machine-readable structure, with no adapter pre-written inside the project.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual result for me isn't Compose support
&lt;/h2&gt;

&lt;p&gt;The formal result is simple: the renderer now supports both XML/View and Jetpack Compose, with two equivalent sample apps and side-by-side output.&lt;/p&gt;

&lt;p&gt;But the thing worth taking away is the shift in how I size up a feature. I used to ask "how long will it take me to even figure out how to do this?" — and that question quietly killed a lot of useful-but-optional ideas before they started. Increasingly the question I can ask instead is "can I state precisely what the result needs to be, and which constraints actually matter?" I didn't know how to implement Compose rendering, and wouldn't have started researching it on my own. I did know exactly what I wanted from the tool. That was enough for the agent to cover the part I didn't know, including the two failures it had to debug on the way to a working public demo.&lt;/p&gt;

&lt;p&gt;I keep the full case study, including the detailed session timeline and the complete set of assets, on my site: &lt;a href="https://hram.github.io/en/articles/android-ui-renderer-compose/" rel="noopener noreferrer"&gt;https://hram.github.io/en/articles/android-ui-renderer-compose/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>mcp</category>
      <category>ai</category>
    </item>
    <item>
      <title>My Coding Agent Built a Gas Station Monitor</title>
      <dc:creator>Evgeny Khramov</dc:creator>
      <pubDate>Wed, 30 Sep 2026 06:05:15 +0000</pubDate>
      <link>https://dev.to/hram/my-coding-agent-built-a-gas-station-monitor-in-one-morning-it-answered-the-wrong-question-5hao</link>
      <guid>https://dev.to/hram/my-coding-agent-built-a-gas-station-monitor-in-one-morning-it-answered-the-wrong-question-5hao</guid>
      <description>&lt;p&gt;At 02:01 one night, Telegram got a message from my personal portal:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🟢 You can go for 95-octane&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It pointed to the right station and said there was no line. About an hour later I decided to use it. Home automation logged the car leaving at &lt;code&gt;03:07&lt;/code&gt; and coming back at &lt;code&gt;03:30&lt;/code&gt;: a 23-minute trip.&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%2Fwfswaq7j6pc3czty2lcy.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%2Fwfswaq7j6pc3czty2lcy.png" alt="A Telegram chat: at 02:01 the portal says 95-octane is confirmed with no line; at 03:07 &amp;quot;car left&amp;quot;, at 03:30 &amp;quot;car arrived&amp;quot; (text in Russian)" width="492" height="256"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The real notification and the two home-automation messages that followed (in Russian).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The useful part isn't that the recommendation happened to be right. It's that until that moment I didn't have to watch anything. Getting there took one wrong turn: the coding agent quickly built a working MVP, and I then realized I had automated the wrong part of the problem.&lt;/p&gt;

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

&lt;p&gt;A fuel shortage turned filling up into a small operational process. There are a few stations near me worth driving to, I only need 95-octane, and "fuel showed up" means little if there's a line of dozens of cars. Public services already reported availability and lines, but every check still meant: open the service, find my stations, check 95 specifically, look at lines, judge how fresh the data is, compare, decide. Then repeat a while later.&lt;/p&gt;

&lt;p&gt;The data existed. What was missing was a personal layer that knew my criteria and did the routine part.&lt;/p&gt;

&lt;h2&gt;
  
  
  MVP: a few &lt;code&gt;curl&lt;/code&gt; calls, a problem description, a template
&lt;/h2&gt;

&lt;p&gt;I gave a coding agent (I don't remember which one, and it doesn't matter here):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a description of the problem;&lt;/li&gt;
&lt;li&gt;about 5–6 &lt;code&gt;curl&lt;/code&gt; examples that made the available API clear;&lt;/li&gt;
&lt;li&gt;a requirement to use my existing FastAPI project template.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The agent worked out the data structure, wrote the project documentation, and implemented the MVP: station search, a watchlist, periodic data collection, history, and a screen with the current state of the selected stations.&lt;/p&gt;

&lt;p&gt;In git, the first commit with the plan landed on September 12 at &lt;code&gt;09:24&lt;/code&gt;, and the MVP commit at &lt;code&gt;11:44&lt;/code&gt;. That is not "2 hours 20 minutes of human work": git doesn't show how much time a person spent or what happened inside the agent session. It does show how fast a raw input became a working product.&lt;/p&gt;

&lt;h2&gt;
  
  
  It worked, and answered the wrong question
&lt;/h2&gt;

&lt;p&gt;Technically the MVP did exactly what I had asked: it collected data, showed my stations, gave a current snapshot. Looking at the whole screen, I still had to read several cards and interpret the situation myself. I had automated getting the data, not the part that annoyed me.&lt;/p&gt;

&lt;p&gt;The first version answered &lt;strong&gt;"What's happening at my gas stations right now?"&lt;/strong&gt; I needed &lt;strong&gt;"Should I go for 95-octane now, or wait?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I took the working screen to ChatGPT, not as a coding task but as a product review: what decision should the user get on the main screen? One AI had acted as the implementer of the original spec; the second one looked at the result from outside. That review produced the idea of turning the monitor into a decision assistant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why one &lt;code&gt;status&lt;/code&gt; field isn't enough
&lt;/h2&gt;

&lt;p&gt;The naive version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;95-octane available -&amp;gt; go
95-octane not available -&amp;gt; don't go
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It fails on real data. The source API has no formal public spec, and different parts of a response can describe the situation with different freshness and semantics. The overall status can say fuel is available while more recent data shows a line of 20–50 cars at the same station.&lt;/p&gt;

&lt;p&gt;A decision has to account for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;whether 95-octane specifically is confirmed;&lt;/li&gt;
&lt;li&gt;how fresh that confirmation is;&lt;/li&gt;
&lt;li&gt;what the line looks like, and how fresh that is;&lt;/li&gt;
&lt;li&gt;whether the current state can be trusted at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And one rule had to be stated explicitly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;No fresh data does not mean no fuel.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If the system doesn't know, it says &lt;code&gt;UNKNOWN&lt;/code&gt; instead of pretending there's no fuel.&lt;/p&gt;

&lt;h2&gt;
  
  
  From snapshots to a deterministic decision
&lt;/h2&gt;

&lt;p&gt;A normalization layer now sits between the external API and the decision:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;API snapshot
    ↓
StationState
    ↓
StateTransition
    ↓
DecisionEngine
    ↓
GO / WAIT / NO_OPTIONS / UNKNOWN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;StationState&lt;/code&gt; describes the situation in the app's own terms: is my fuel available, what's the line, when was it observed, how fresh is the data. &lt;code&gt;DecisionEngine&lt;/code&gt; looks at all watched stations at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;GO&lt;/code&gt;: at least one suitable station has confirmed 95-octane and an acceptable line;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;WAIT&lt;/code&gt;: fuel is available, but the options don't work for me, e.g. because of the line;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NO_OPTIONS&lt;/code&gt;: no watched station has a confirmed suitable option;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;UNKNOWN&lt;/code&gt;: fresh data isn't enough for an honest decision.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is a plain deterministic algorithm. &lt;strong&gt;There's no LLM at runtime.&lt;/strong&gt; The answer had to be repeatable, depend on measurable conditions, be easy to diagnose, be explainable through the underlying facts, and behave the same after every restart. AI was used to build the system, review the interface, and design and implement changes; the runtime decision is plain rules.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;AI helps build the decision system, but the decision system itself doesn't have to be an AI system.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Decision first, evidence after
&lt;/h2&gt;

&lt;p&gt;The station cards stayed, but became secondary. The main element is the aggregate verdict with an explanation, for example &lt;strong&gt;GO&lt;/strong&gt; followed by "95-octane confirmed: Gazpromneft, Primorskoe Highway 251 — no line."&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%2Fer20w91xh9cvmcck212h.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%2Fer20w91xh9cvmcck212h.png" alt="The main screen: a green &amp;quot;GO&amp;quot; verdict with the confirming station at the top, and cards for six watched stations with 95-octane status and data confidence below (UI in Russian)" width="800" height="575"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Top: the decision across all stations. Below: the per-station data it's based on (UI in Russian).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The first version said: here are three cards, figure it out. The second says: you can go now, here's why, and the underlying data is below.&lt;/p&gt;

&lt;p&gt;Each station also has a history by day, week or month, with an hour-by-hour timeline: 95 with no line, short line, long line, no 95, no data. There is no ML model predicting when a fuel truck arrives. The history lets a human spot patterns (what part of the day a station tends to get 95, when lines form), so the operational decision goes to the algorithm and the deeper interpretation stays with me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hardest part: teaching it to stay quiet
&lt;/h2&gt;

&lt;p&gt;Once &lt;code&gt;DecisionEngine&lt;/code&gt; existed, the system started understanding transitions: fuel appeared, the line changed, data recovered, the verdict changed. Every one of them is interesting technically. To the user, most aren't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fuel appeared
↓
the line is still too long
↓
final verdict = WAIT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Telegram message about that demands attention and changes nothing about what I do. The rule became:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I don't care about every transition and intermediate status. I only want to know when I can actually go.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Telegram stopped being a log of internal events and became a channel for actionable events only. The pipeline got a third step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;data
  ↓
decision
  ↓
is it worth interrupting the person right now?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The detailed log didn't go away; it moved inside the portal as an audit trail. For every Telegram message you can see the previous state, what changed, what event occurred, whether the overall verdict changed, why the portal decided to notify, and the exact text sent. If it tells me "go" at three in the morning, I can reconstruct afterwards exactly which change made it decide to bother me. With a rule-based system, that full cause-and-effect chain is available.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who did what
&lt;/h2&gt;

&lt;p&gt;The coding agent got a few &lt;code&gt;curl&lt;/code&gt; calls, a problem description and a template, and built the first working service. After that, AI implemented the new state models, the decision engine, transitions, Telegram, history, audit and tests.&lt;/p&gt;

&lt;p&gt;The most important changes didn't come from the code. The MVP worked, and only seeing it in a real interface showed it answered the wrong question. Then the working Telegram integration exposed that a correct event system can still be too chatty.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;real problem → task for the agent → working MVP
→ human looks at the result → wrong thing was automated
→ external AI review → new product model
→ real-world use → one more simplification
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I spent much less time implementing classes and more on: what problem the system actually solves, how to tell the first version isn't enough, which decision can be trusted to an algorithm, what data is sufficient, when the system may interrupt a person, and whether the result is useful in real life. Sometimes a working product is exactly what you need to see that the task should have been framed differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  A thin personal layer over mass-market data
&lt;/h2&gt;

&lt;p&gt;Existing services aren't bad; they show information to thousands of people with different routes, cars and tolerance for lines. They don't need to know that I care only about 95-octane, only a few stations, won't accept a certain line, and want to be called only when there's a practical reason to go.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;someone else's data
+ my context
+ my rules
+ my history
+ my notifications
= a personal decision assistant
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A service like this for one person used to look disproportionately expensive in time. With coding agents, that experiment costs something else entirely.&lt;/p&gt;

&lt;p&gt;The best quality criterion I found for it isn't the number of screens or endpoints, or whether "AI" appears in the architecture. It's that most of the time the system asks nothing of me, and shows up exactly when there's something for me to do.&lt;/p&gt;

&lt;p&gt;The full case study, including the first MVP screen, the station history and the Telegram audit log, is on my site: &lt;a href="https://hram.github.io/en/articles/gdebenz/" rel="noopener noreferrer"&gt;https://hram.github.io/en/articles/gdebenz/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>fastapi</category>
      <category>ai</category>
      <category>sideprojects</category>
    </item>
    <item>
      <title>My Coding Agent Could Render One Android Layout. A Real Screen Needed Activity, Fragment, RecyclerView and Overlays.</title>
      <dc:creator>Evgeny Khramov</dc:creator>
      <pubDate>Tue, 29 Sep 2026 20:32:52 +0000</pubDate>
      <link>https://dev.to/hram/my-coding-agent-could-render-one-android-layout-a-real-screen-needed-activity-fragment-2fp6</link>
      <guid>https://dev.to/hram/my-coding-agent-could-render-one-android-layout-a-real-screen-needed-activity-fragment-2fp6</guid>
      <description>&lt;p&gt;My coding agent could already render a single Android XML layout through Robolectric and get back a PNG plus a View Tree with pixel bounds. Then I pointed it at a real screen from a work app, and one layout stopped being enough.&lt;/p&gt;

&lt;p&gt;A real screen is an Activity shell, a Fragment container, a RecyclerView with data, and states like a loading overlay. Launching the production Fragment would drag in DI, navigation, networking and the rest of the app's runtime. I wanted something narrower: a screen real enough for visual checks, without running the app.&lt;/p&gt;

&lt;p&gt;This is how &lt;a href="https://github.com/hram/android-ui-renderer-mcp" rel="noopener noreferrer"&gt;android-ui-renderer-mcp&lt;/a&gt; got composed-screen rendering.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The work screen can't be shown, so every image here is a render of an open demo app in the same repository (&lt;a href="https://github.com/hram/android-ui-renderer-mcp/tree/main/sample" rel="noopener noreferrer"&gt;&lt;code&gt;sample/&lt;/code&gt;&lt;/a&gt;). It has the same structure — Activity with a toolbar, Fragment, RecyclerView, master-detail, loading overlay — and each image can be reproduced from the request stored next to it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The boundary: real resources, explicit state
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Activity XML          — real
Fragment XML          — real
item layout           — real
drawable              — real

Fragment runtime      — not run
DI                    — not run
navigation            — not run
network               — not run
data                  — set explicitly
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the main design decision: the renderer uses the app's real UI resources, but takes runtime state from a deterministic request. Otherwise it becomes one more way to run the app, only harder than an emulator.&lt;/p&gt;

&lt;h2&gt;
  
  
  Activity + Fragment as one render target
&lt;/h2&gt;

&lt;p&gt;A new &lt;code&gt;render_target&lt;/code&gt; tool supports one target kind, &lt;code&gt;activity_fragment&lt;/code&gt;. It inflates the Activity XML, finds the container, inflates the Fragment XML separately and inserts it. The production Fragment class never starts.&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;"target"&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;"activity_fragment"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"activityLayout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"activity_main"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"containerId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fragmentContainer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"fragmentLayout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fragment_library"&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;"widthPx"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1920&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"heightPx"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"densityDpi"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;240&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"orientation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"landscape"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;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%2F6sr7uouo3pmisj80euwt.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%2F6sr7uouo3pmisj80euwt.png" alt="An Activity with a toolbar and a master-detail fragment, assembled from XML without running Activity or Fragment code" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The toolbar title and menu icons come from XML (&lt;code&gt;app:title&lt;/code&gt;, &lt;code&gt;app:menu&lt;/code&gt;); no Activity code ran. The details on the right are an &lt;code&gt;&amp;lt;include&amp;gt;&lt;/code&gt;, filled from the request.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I'm not emulating the Fragment lifecycle. I need its visual result from real resources, because the agent changes XML and has to see what ends up on screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deterministic RecyclerView rows
&lt;/h2&gt;

&lt;p&gt;An empty RecyclerView says little about the real UI, and running the production adapter would pull real data back in. So list data became part of the request: the real &lt;code&gt;itemLayout&lt;/code&gt;, the orientation, the rows, and a fixture per row. The renderer creates a temporary adapter and inflates the real item XML for each row.&lt;/p&gt;

&lt;p&gt;One row from the request above, the selected book:&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;"@id/title"&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;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Designing Data-Intensive Applications"&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;"@id/cover"&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;"image"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"drawable_resource"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@drawable/cover_green"&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;"@id/statusBadge"&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;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"On loan"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"backgroundDrawable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@drawable/bg_badge_on_loan"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"textColor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#8A4B08"&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;"@id/root"&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;"selected"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"backgroundDrawable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@drawable/bg_book_row_selected"&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;&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%2Fsddlhw1iuv5ozlvqwjit.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%2Fsddlhw1iuv5ozlvqwjit.png" alt="A fragment with a RecyclerView: four rows inflated from the real item layout" width="720" height="1280"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The UI structure is real; the data is test data and fully controlled. The renderer doesn't guess values from &lt;code&gt;tools:*&lt;/code&gt;. If the agent wants three rows, it describes three rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frozen spinners for loading overlays
&lt;/h2&gt;

&lt;p&gt;A loading overlay above Activity + Fragment is just another XML layout with its own fixture. The catch is the indeterminate ProgressBar: it animates, so two screenshots in a row can differ. For automated visual checks that's needless nondeterminism, so the renderer freezes it into a static ring before drawing.&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%2Frx81wgcnta6w975h15zl.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%2Frx81wgcnta6w975h15zl.png" alt="The same composed screen with a loading overlay; the spinner is frozen into a static ring" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This doesn't prove the animation works. It proves the indicator exists, sits in the right place and has the expected geometry, which is the level of realism this task needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Render on the real device's area
&lt;/h2&gt;

&lt;p&gt;For the work screen I also pinned down the real tablet profile: 1280×800 screen, 1280×728 app area (72 px at the bottom taken by system navigation), &lt;code&gt;densityDpi&lt;/code&gt; 240, &lt;code&gt;fontScale&lt;/code&gt; 1.0, &lt;code&gt;ru-RU&lt;/code&gt;, day mode, landscape. For a screenshot that's a detail; for comparing bounds it isn't. The agent has to measure in the same area the real screen has.&lt;/p&gt;

&lt;h2&gt;
  
  
  A render you can't replay isn't an experiment
&lt;/h2&gt;

&lt;p&gt;At some point I opened a saved render and realized its PNG and View Tree were there, but the call that produced them wasn't: which tool, which arguments, which project root, module, variant and Gradle test task. A reproducible renderer with a non-reproducible run history.&lt;/p&gt;

&lt;p&gt;Every sidecar run now also saves &lt;code&gt;request.json&lt;/code&gt; (the normalized request) and &lt;code&gt;replay.json&lt;/code&gt; (the recipe). For the overlay screen above, shortened:&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;"format"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"android-ui-renderer-mcp/replay/v1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tool"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"render_target"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&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;"target"&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;"activity_fragment"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"activityLayout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"activity_main"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"containerId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fragmentContainer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"fragmentLayout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fragment_library"&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;"widthPx"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1920&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"heightPx"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"densityDpi"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;240&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"orientation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"landscape"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"overlays"&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;"layout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"view_loading_overlay"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"fixture"&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;"@id/loadingRoot"&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;"visibility"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"visible"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"project"&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;"root"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sample"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"module"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;":app"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"variant"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"debug"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"testTask"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;":app:testDebugUnitTest"&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;A render session is now a small experiment you can repeat on the same project revision and renderer version.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the open demo caught that the work project didn't
&lt;/h2&gt;

&lt;p&gt;I built the demo later, only to have images I could publish. It found real issues.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The renderer silently required JUnit.&lt;/strong&gt; The first demo render failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error: package org.junit does not exist
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The probe is a JUnit 4 test, but the init script added only Robolectric. The work project had JUnit in its test dependencies, like any project from the default Android Studio template, so this never showed up. Now JUnit 4.13.2 is added for the render run only if the project doesn't declare it; a project on JUnit 4.12 stays on 4.12, checked against the test classpath.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A night-mode contrast bug in the demo itself.&lt;/strong&gt; An outlined button was almost invisible because the theme's primary color matched the dark toolbar. The &lt;code&gt;nightMode: true&lt;/code&gt; render showed it; reading the XML didn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Byte-identical re-renders.&lt;/strong&gt; Re-running all seven demo scenarios on the same machine produced PNGs identical to the saved ones. With &lt;code&gt;replay.json&lt;/code&gt;, a replay gives the same image, not a similar one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The View Tree sees what the PNG hides.&lt;/strong&gt; A long title at &lt;code&gt;fontScale: 1.3&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%2F98xa9tyqdwknlef7i20q.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%2F98xa9tyqdwknlef7i20q.png" alt="A list row at fontScale 1.3: the title is cut off with an ellipsis but looks plausible" width="800" height="256"&gt;&lt;/a&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="nl"&gt;"textLayout"&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;"textSizePx"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;40.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maxLines"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"lineCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ellipsisCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;81&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"truncated"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bounds didn't change and the image looks fine, but 81 characters became an ellipsis. Also, 16sp at &lt;code&gt;fontScale: 1.3&lt;/code&gt; is 40 px, not 41.6: since Android 14, large text scales non-linearly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who did what
&lt;/h2&gt;

&lt;p&gt;I have a detailed worklog for this iteration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Me:&lt;/strong&gt; picked the real screen as a testing ground and made the work app read-only; asked for RecyclerView support; supplied a mockup as the source of test data and the real tablet profile; stopped the agent before the overlay/dialog changes and asked for analysis first; chose a separate &lt;code&gt;render_target&lt;/code&gt; over overloading &lt;code&gt;render_layout&lt;/code&gt;; noticed that renders couldn't be replayed; checked the composed render with the overlay myself before allowing the commit. For the demo, I defined the screens and asked for JUnit to be added only when missing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The coding agent:&lt;/strong&gt; studied the real screen's XML and the renderer's limits, proposed the deterministic RecyclerView adapter, implemented list and drawable-state support, the composed target, overlays and the replay recipe, ran checks on an isolated copy of the work project without changing it, and wrote the demo app, its render requests and the JUnit fix.&lt;/p&gt;

&lt;p&gt;I didn't type most of the code. I decided which part of reality the tool should model and what it must not do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Only &lt;code&gt;activity_fragment&lt;/code&gt;: one container, one fragment layout. Master-detail in the demo is a single fragment layout, not two fragments.&lt;/li&gt;
&lt;li&gt;RecyclerView gets only explicit local rows. Pagination, async loading and real data sources are not modeled.&lt;/li&gt;
&lt;li&gt;A frozen spinner checks presence and geometry, not animation.&lt;/li&gt;
&lt;li&gt;Robolectric is not a device.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Each step of this project came down to one question: &lt;strong&gt;which part of the real app must stay real, and which can be replaced with a deterministic model?&lt;/strong&gt; My current answer: resources and geometry stay real; data and runtime state are explicit and controlled; and every render leaves enough behind to be repeated.&lt;/p&gt;

&lt;p&gt;The renderer and the demo are open source: &lt;a href="https://github.com/hram/android-ui-renderer-mcp" rel="noopener noreferrer"&gt;hram/android-ui-renderer-mcp&lt;/a&gt;. The full case study is on my site: &lt;a href="https://hram.github.io/en/articles/android-ui-renderer-composed-screens/" rel="noopener noreferrer"&gt;https://hram.github.io/en/articles/android-ui-renderer-composed-screens/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>mcp</category>
      <category>ai</category>
      <category>testing</category>
    </item>
    <item>
      <title>I Asked AI to Improve My Resume. It Started Asking Me for Numbers Instead.</title>
      <dc:creator>Evgeny Khramov</dc:creator>
      <pubDate>Tue, 29 Sep 2026 20:23:35 +0000</pubDate>
      <link>https://dev.to/hram/i-asked-ai-to-improve-my-resume-it-started-asking-me-for-numbers-instead-5bem</link>
      <guid>https://dev.to/hram/i-asked-ai-to-improve-my-resume-it-started-asking-me-for-numbers-instead-5bem</guid>
      <description>&lt;p&gt;The usual AI resume flow 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;old CV + job posting → LLM → improved CV
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It has a basic flaw. My old CV was already a heavily trimmed version of my professional history. If I didn't think something was important and didn't write it down for ten years, the model doesn't know about it either. It can only rephrase what's already there.&lt;/p&gt;

&lt;p&gt;I hit this while preparing for a job search. Over the last six months the way I develop software changed a lot: more and more of the mechanical work (reading a codebase, writing code, fixing, refactoring, part of the analysis) goes to AI agents. Formally I'm still a developer and tech lead, but "Android Tech Lead" describes less and less of what I actually do. So the real question wasn't how to phrase my resume. It was &lt;strong&gt;what job should I even be looking for next?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So I flipped the flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;facts and context first → then AI → and only then a resume
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result is Career Portal, a private knowledge base about my own professional life. The resume turned out to be the last step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store the context the fact was true in
&lt;/h2&gt;

&lt;p&gt;The portal keeps a profile, jobs, projects, skills and notes, and context is split by level:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Profile&lt;/strong&gt;: who I am and what I want.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Job&lt;/strong&gt;: what role I held during that period.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Project&lt;/strong&gt;: what I did there, with what stack, in what role, with what result.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This looks excessive until you describe several years at one company. At one employer I might have an Android project where I own the mobile app and the team, a Kotlin/Ktor BFF where I'm a backend developer and architect, and elsewhere KMP, internal tooling, CI/CD or platform work. Dump all of that into one "Work experience" field and the model mixes contexts: technology from one project lands next to an achievement from another, and a Tech Lead role spreads onto work where I was a regular developer.&lt;/p&gt;

&lt;p&gt;The principle: &lt;strong&gt;it's not enough to save a fact; you need to save the context in which it was true.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There's also a private free-form "About me" field. Things like "I don't want this role anymore" or "I managed people here but don't remember the exact team size" aren't resume material yet, but they are context for the AI later. And I don't want every intermediate edit to a public CV to read as "he's job hunting again."&lt;/p&gt;

&lt;h2&gt;
  
  
  The most useful AI output wasn't text
&lt;/h2&gt;

&lt;p&gt;After filling in the profile you can run a review. I expected the usual advice: trim, reorder, make wording stronger. Instead the agent started asking questions.&lt;/p&gt;

&lt;p&gt;I wrote that I managed Android teams. It asked: &lt;strong&gt;exactly how many people?&lt;/strong&gt; I wrote about working fully remote. It asked whether I'd consider hybrid in St. Petersburg. I listed several career directions. It asked which is a current goal and which is just an option.&lt;/p&gt;

&lt;p&gt;At the job level it gets more uncomfortable, in a good way:&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%2Fkhl6afo2hrqvm9gzw62d.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%2Fkhl6afo2hrqvm9gzw62d.png" alt="Career Portal reviewing a job entry: a readiness score, questions, weak spots, recommendations and strengths (UI in Russian)" width="800" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The job review (UI in Russian). Left: questions and weak spots, such as "you wrote 'sped up development several times', is there a number?" and "the Team field is empty". Right: recommendations and strengths, such as the crash-free 60% → 99.8% metric.&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Significantly sped up development" → &lt;strong&gt;by how much?&lt;/strong&gt; Did the release cycle go from six weeks to two? One release a month to four?&lt;/li&gt;
&lt;li&gt;"Reduced incident response time" → from what value to what value?&lt;/li&gt;
&lt;li&gt;"Moved development from a contractor to in-house" → team size, project scale, and what changed in timelines, cost or speed?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At the project level, one of my facts already had a clear before → after: crash-free rate went from roughly 60% to 99.8%. There was almost nothing to improve. Right next to it sat "significantly sped up development", which is clear to me because I remember the context and nearly empty to anyone reading the resume for the first time.&lt;/p&gt;

&lt;p&gt;I always assumed metrics were mainly a manager's territory. It turned out I need them just as much to explain my own work: team size, devices in production, number of stores, releases, cycle time, error rate, operation volume. Not everything is money, but "I improved the process" and "before → after" are very different claims.&lt;/p&gt;

&lt;p&gt;The most useful thing the AI did wasn't writing about me. &lt;strong&gt;It asked about the things I didn't think to write down.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Then: what can this experience even be called?
&lt;/h2&gt;

&lt;p&gt;My problem wasn't phrasing &lt;code&gt;Head of Mobile&lt;/code&gt; nicely. I wasn't sure I should be looking for &lt;code&gt;Head of Mobile&lt;/code&gt; at all. I didn't want to pick a title and stretch a biography over it; I wanted to collect what I actually did first and see which roles follow.&lt;/p&gt;

&lt;p&gt;The "Suggest a role" feature looks at the profile, experience, projects, stack and results, and proposes positioning options with reasons:&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%2F6quh53i15eegexbcgulj.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%2F6quh53i15eegexbcgulj.png" alt="Career Portal suggesting roles such as Head of Mobile and Mobile Engineering Manager, each with a fit label, focus areas and reasons (UI in Russian)" width="800" height="598"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Role suggestions (UI in Russian). Each role gets a fit label, what to emphasize, and why it fits, citing facts from the profile.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;My options included &lt;code&gt;Head of Mobile&lt;/code&gt;, &lt;code&gt;Mobile Engineering Manager&lt;/code&gt;, and later &lt;code&gt;Lead Android Architect&lt;/code&gt;, &lt;code&gt;Mobile Platform Lead&lt;/code&gt; and a few others. The AI &lt;strong&gt;doesn't decide who I should be&lt;/strong&gt;. It proposes a hypothesis with arguments from my own history; I accept, edit or ignore it.&lt;/p&gt;

&lt;p&gt;A saved role becomes a separate view of the same fact base. These aren't three versions of me, just different selections of real facts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Head of Mobile&lt;/code&gt;: team management, hiring, moving development in-house, engineering standards, release processes.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Lead Android Architect&lt;/code&gt;: Android/Kotlin, architectural decisions, stability, modernization, hands-on work.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Mobile Platform Lead&lt;/code&gt;: CI/CD, platform mechanisms, feature toggles, KMP, internal infrastructure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With one fact base and a model that isn't allowed to invent achievements, this is choosing which part of a real history is relevant, not bending a biography to a job posting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resume generation comes last, and publishing stays manual
&lt;/h2&gt;

&lt;p&gt;After choosing a role, the portal can help search for postings in that direction and assemble a resume for the role, or narrower, for a specific posting. I deliberately avoided building it around "Am I an 82% match?". The question I want answered is: &lt;strong&gt;what from my actual experience needs to be shown for this resume to answer this posting's requirements?&lt;/strong&gt; The posting becomes context for selecting facts, not a verdict on the candidate.&lt;/p&gt;

&lt;p&gt;The result is saved as a separate resume, in the UI and as files, and the last step is manual: I decide what gets published. For career data I don't want an agent that invents a role, writes a document and sends it out with no human in between.&lt;/p&gt;

&lt;h2&gt;
  
  
  It isn't a programmer-only problem
&lt;/h2&gt;

&lt;p&gt;Later I made the portal multi-user and loaded my wife's resume. She's an accountant. The same pattern repeated:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How many legal entities were you handling?&lt;/li&gt;
&lt;li&gt;How many invoices did you issue per month on average?&lt;/li&gt;
&lt;li&gt;How many material write-off transactions?&lt;/li&gt;
&lt;li&gt;What was the monthly volume?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mechanism has nothing to do with Android or Kotlin. A person writes "I worked on X"; the AI answers "what was the scale of X, and what backs that up?"; the person goes back to their real history and fills it in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stack and authorship
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;FastAPI, Jinja2 and SQLite; no heavy SPA.&lt;/li&gt;
&lt;li&gt;The database holds a profile, jobs with nested projects, skills, education, notes, roles, generated resumes and job-posting data.&lt;/li&gt;
&lt;li&gt;PDF/DOCX import: text is parsed, a preview is built, and data lands in the database only after confirmation. Replacing an already filled profile needs separate confirmation.&lt;/li&gt;
&lt;li&gt;AI providers: Claude CLI, Codex CLI, and an OpenAI-compatible provider. Job board integration came after the first MVP.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The project kept its genesis trail. The master plan spelled out the roles: Claude in chat is the architect, Claude Code is the executor, I'm the owner. An agent wrote every line of the portal's code. I didn't write a single line, and I didn't read one either. My part was the task, the roles, the specs, and the decisions about what the product should be.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest result: almost nobody writes to me
&lt;/h2&gt;

&lt;p&gt;I'm using the portal for a real job search. The resume is much more detailed and evidence-backed, I found plenty of gaps in my own history, added metrics, and looked at roles differently.&lt;/p&gt;

&lt;p&gt;But there are almost no calls or messages, and I don't know why. It could be the market, my positioning, the resume itself, or I'm optimizing the wrong part of the process. One uncomfortable hypothesis: iterating on a resume with AI for a long time can land you in a local maximum, where the document gets more logical to you and the model without working better in actual hiring. I don't have data to state that as a fact.&lt;/p&gt;

&lt;p&gt;So I don't claim the portal solved my job search. It solved a different problem: my professional history is stored separately from its publications, claims were turned into evidence, I saw several possible roles, and I can build different resumes from one fact base. Whether they work is up to the market.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;A resume is a temporary snapshot of a career, not its storage. What matters comes earlier: facts, context, projects, real roles, scale, numbers, preferences and constraints.&lt;/p&gt;

&lt;p&gt;If that base is weak, AI rewrites weak material nicely. If it's good, AI can find a gap, ask an uncomfortable question, suggest an unexpected positioning, and assemble different documents from the same history. What I don't hand over: deciding who I should be, inventing achievements, deciding whether to apply, and publishing the final version.&lt;/p&gt;

&lt;p&gt;I wanted AI to write about me better. First I needed to know and prove my own professional history much better myself.&lt;/p&gt;

&lt;p&gt;The full case study, with more screenshots of the portal, is on my site: &lt;a href="https://hram.github.io/en/articles/career-portal/" rel="noopener noreferrer"&gt;https://hram.github.io/en/articles/career-portal/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>career</category>
      <category>python</category>
      <category>sideprojects</category>
    </item>
    <item>
      <title>My AI Running Coach Didn't Need a Better Prompt. It Needed My Training History.</title>
      <dc:creator>Evgeny Khramov</dc:creator>
      <pubDate>Tue, 29 Sep 2026 04:42:00 +0000</pubDate>
      <link>https://dev.to/hram/my-ai-running-coach-didnt-need-a-better-prompt-it-needed-my-training-history-22f8</link>
      <guid>https://dev.to/hram/my-ai-running-coach-didnt-need-a-better-prompt-it-needed-my-training-history-22f8</guid>
      <description>&lt;p&gt;After every run I used to open the Mi Fitness app, take a few screenshots, and upload them to ChatGPT for analysis. On paper, that was a personal coach: the watch collects the data, I show it to AI, AI tells me what to do next.&lt;/p&gt;

&lt;p&gt;In practice it fell apart quickly, and not because of the prompt.&lt;/p&gt;

&lt;p&gt;A single workout doesn't fit on one screenshot: summary, charts, heart rate zones and other metrics all live on separate screens. Charts were the worst: a human can stitch several screens into one picture, but for the chat I was rebuilding the context from fragments every time.&lt;/p&gt;

&lt;p&gt;The bigger problem: &lt;strong&gt;one workout means almost nothing without the previous ones.&lt;/strong&gt; A heart rate of 168 is just a number. It is a different story if at roughly the same distance it was 158, then 164, 167, 168, 170 and 172. Useful advice needs a trend, and through screenshots I was bringing AI a small fragment of what the watch app already knew.&lt;/p&gt;

&lt;p&gt;So the problem wasn't finding a better prompt. I needed a real channel between the watch data and the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the model gets now
&lt;/h2&gt;

&lt;p&gt;This is a shortened fragment of a real coach prompt that the portal assembles after a run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== RUN ===
Distance: 3.216 km
Heart rate: 168 bpm
Pace: 6:04/km
Cadence: 162 spm
Load: 127
Recovery: 57 h
Heart rate zones: ... extreme 96%

=== RECENT RUNS ===
09/14: 3.271 km, HR 172, load 142
09/12: 3.307 km, HR 170, load 139
09/10: 3.216 km, HR 168, load 127
...

=== RESPONSE ===
1. Assessment of the run.
2. Risks given the current state.
3. One specific recommendation for the next workout.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real request contains more: the current run, the history of recent workouts, and stored context about my state. I can edit the template itself from the portal's UI.&lt;/p&gt;

&lt;p&gt;That is the whole difference from screenshots. Before, I showed AI a &lt;em&gt;picture&lt;/em&gt; of the data. Now the code gathers the facts and builds the context, I set the rules and the response format, and the model interprets the context.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data layer: running-portal
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/hram/running-portal" rel="noopener noreferrer"&gt;&lt;code&gt;running-portal&lt;/code&gt;&lt;/a&gt; syncs my running workouts from Mi Fitness, stores the history, and loads detailed data for a specific run. For me it is an ordinary training log. For the model it is a persistent source of context.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Mi Fitness
    ↓
sync
    ↓
running-portal
    ├── run history
    ├── detailed metrics
    ├── charts
    ├── state / how I feel
    └── monthly goal
            ↓
        AI coach
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I did not reverse-engineer the private Mi Fitness API. The sync module is based on &lt;a href="https://github.com/kevinkwee/Mi-Fitness-Sync" rel="noopener noreferrer"&gt;Mi-Fitness-Sync by kevinkwee&lt;/a&gt; (MIT License), which already implemented Xiaomi authorization, fetching workouts, and parsing detailed Mi Fitness data. A coding agent wrote &lt;code&gt;running-portal&lt;/code&gt; and integrated that code; credit for the protocol work goes to the author of Mi-Fitness-Sync.&lt;/p&gt;

&lt;p&gt;The integration is not pleasant: a private API and internal data formats, including separate files with detailed value series. That is one reason I treat the portal as a personal tool, not the basis for a public service.&lt;/p&gt;

&lt;p&gt;The workout card shows me the same things that go to the model in structured form: heart rate, pace, cadence, stride length, load, recovery, heart rate zone distribution, and detailed time series.&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%2Fqg0amzaihkqvgscsxve3.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%2Fqg0amzaihkqvgscsxve3.png" alt="Run details in running-portal: metrics, heart rate zones, and a pace and heart rate chart" width="799" height="572"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;To me, charts. To the model, raw numbers.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the coach does with it
&lt;/h2&gt;

&lt;p&gt;The analysis shows up on the same workout card.&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%2Fghv38uxgi4uf7vjak8b8.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%2Fghv38uxgi4uf7vjak8b8.png" alt="AI breakdown of a run: assessment, health context, and a recommendation for the next workout" width="800" height="498"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Assessment → risks → one specific next session.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In this example the coach looks beyond average heart rate: most of the run happened in a high heart rate zone, it compares that with previous runs, and it suggests not increasing the load. I set a rigid response structure on purpose. I don't need sports motivation; I need an assessment, the risks, and the next workout.&lt;/p&gt;

&lt;p&gt;This is where I stopped thinking of the prompt as magic text. The usefulness came from the context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guardrails in code, explanation from the LLM
&lt;/h2&gt;

&lt;p&gt;The portal also answers a shorter daily question: run today, run easy, or rest?&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%2Fc1bwsh7skr0denk0dbyv.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%2Fc1bwsh7skr0denk0dbyv.png" alt="The &amp;quot;Today's answer&amp;quot; card showing a &amp;quot;Rest&amp;quot; status" width="800" height="269"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;At the time of this screenshot, less than the estimated recovery time had passed since the last run.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here I don't hand the decision fully to the model. The prompt has explicit rules: for example, if the estimated recovery time after the previous workout hasn't passed yet, that is a signal in favor of rest. High heart rate, load, and saved notes about my state are factored in separately. The portal fills in the facts automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;last run
+ how much time has passed
+ last 7 runs
+ load
+ recovery
+ saved context
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the model writes a short explanation. Part of the guardrails is explicit, and the LLM is used where several factors need to be connected and explained in plain language.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why long-term context mattered
&lt;/h2&gt;

&lt;p&gt;While getting back into regular running, I had problems with my feet, and later my knee bothered me for a long time. This is not a medical success story: AI didn't cure anything and didn't replace a doctor.&lt;/p&gt;

&lt;p&gt;It was useful as a check on my own tendency to increase load too fast. When pain showed up in the context, the coach lowered the recommended load, suggested a pause, and advised seeing a doctor. After I bought new 361 KAIROS 2 shoes, the shoe change went into the context too, as one more factor against sharply increasing volume. With the knee, the portal kept the note in context and the model kept factoring it into later recommendations.&lt;/p&gt;

&lt;p&gt;A one-off chat easily forgets what happened two weeks ago. The portal doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Arithmetic where arithmetic is enough
&lt;/h2&gt;

&lt;p&gt;Later the task changed from "don't force it" to "what volume should I aim for next month?". At the start of the month, AI analyzes previous runs and proposes three goal options: conservative, recommended, and ambitious. I choose.&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%2Flpb8aivp4kio8qjy8biz.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%2Flpb8aivp4kio8qjy8biz.png" alt="Three monthly goal options: conservative, recommended, and ambitious" width="800" height="305"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The model proposes scenarios and explains the recommended one; the final decision is mine.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Once a goal is accepted, AI is barely needed. Plain portal code counts kilometers run, number of workouts, remaining distance, and whether the month is on track. Where arithmetic is enough, there should be arithmetic; the LLM shows up where history needs to be interpreted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who wrote the portal
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;running-portal&lt;/code&gt; is 100% implemented by coding agents from my specs, except for the Mi Fitness integration foundation mentioned above. It wasn't generated once from one big prompt. The loop was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;use the portal
      ↓
see what's missing
      ↓
formulate the task for the coding agent
      ↓
get the change
      ↓
use the portal again
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repository keeps 11 prompt artifacts from different stages: sync, frontend, the AI coach, charts, analytics, monthly goals and other improvements. I can't reconstruct every agent session behind them anymore, so I don't treat the number of files as a precise development log.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A personal portal for a single user.&lt;/li&gt;
&lt;li&gt;Depends on Mi Fitness's private mechanisms, which can change.&lt;/li&gt;
&lt;li&gt;Simple FastAPI and SQLite architecture; some background work runs inside the app process.&lt;/li&gt;
&lt;li&gt;Not a product I'm ready to hand to thousands of users.&lt;/li&gt;
&lt;li&gt;The AI coach is not a medical system. I treat its recommendations as feedback about training load, not diagnosis or treatment.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;When I started sending screenshots to ChatGPT, I thought the task was learning to ask the right way. A good question turned out to be the smallest part of the system.&lt;/p&gt;

&lt;p&gt;The watch already collected the data, and the LLM could already reason about it. What was missing was a layer that stores the training history, surfaces the right details, adds up-to-date context, and hands it to the model in a predictable form. Close to a year after getting back into running, every workout lands in the portal, and AI works with my running history instead of a few pictures I show it after each run.&lt;/p&gt;

&lt;p&gt;The source is on GitHub: &lt;a href="https://github.com/hram/running-portal" rel="noopener noreferrer"&gt;hram/running-portal&lt;/a&gt;. Suggestions and bug reports are welcome. The full case study, with more screenshots, is on my site: &lt;a href="https://hram.github.io/en/articles/running-portal/" rel="noopener noreferrer"&gt;https://hram.github.io/en/articles/running-portal/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>python</category>
      <category>sideprojects</category>
    </item>
  </channel>
</rss>
