<?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>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 " 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 " 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 " 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>
    <item>
      <title>Why a Coding Agent Needs an MCP for Allure TestOps When a Human Can Just Open the Browser</title>
      <dc:creator>Evgeny Khramov</dc:creator>
      <pubDate>Mon, 28 Sep 2026 14:10:45 +0000</pubDate>
      <link>https://dev.to/hram/why-a-coding-agent-needs-an-mcp-for-allure-testops-when-a-human-can-just-open-the-browser-5bdn</link>
      <guid>https://dev.to/hram/why-a-coding-agent-needs-an-mcp-for-allure-testops-when-a-human-can-just-open-the-browser-5bdn</guid>
      <description>&lt;p&gt;Developers on our team switched to working through coding agents fairly quickly. In my experience, development speed grew several times over, because a large share of routine work with code moved to agents. The processes around the code did not automatically get faster.&lt;/p&gt;

&lt;p&gt;Testing is where it became visible. My QA colleague was heavily loaded, and I asked her a simple question: how can I help? That question eventually became a small &lt;a href="https://github.com/hram/allure-testops-mcp" rel="noopener noreferrer"&gt;MCP server for Allure TestOps&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The obvious objection: why give an agent an MCP for TestOps if a human can just open it in a browser? Because the browser was never the problem. The problem was that &lt;strong&gt;the developer already works through an agent, while a human still manually carries information between code, the TMS, and other tools.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The human as a transport layer
&lt;/h2&gt;

&lt;p&gt;Before this there was no MCP integration with TestOps in our workflow, only the regular web UI. Test cases were created there, manually matched to automated tests, someone had to keep the documentation from drifting away from the code, and mocks were prepared separately.&lt;/p&gt;

&lt;p&gt;Take a simple task: there is a working UI test in the code, and a test case needs to exist in TestOps for it. Without integration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AI agent
    ↓
explains to the human what it found in the code
    ↓
human opens TestOps
    ↓
creates or edits the test case
    ↓
goes back to the agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent can analyze the code but stops at the TestOps boundary. From there the human is the transport between two systems. That is the spot I wanted to remove.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scenario 1: code → TestOps
&lt;/h2&gt;

&lt;p&gt;Our Android project has a lot of UI tests, and they are structured much like tests in a TMS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Step 1
  action
  assertions

Step 2
  action
  assertions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most of the information for a test case already exists, just in the code, not in TestOps. With the MCP connected, I can tell the agent:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Create a test case based on this test.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The agent reads the source UI test, parses its steps and assertions, and creates the corresponding test case in Allure TestOps. It is not inventing documentation from scratch: the source is the code of a real, existing automated test. The MCP only lets the result of that analysis land directly in the system where test documentation lives.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Android UI test → AI agent → TestOps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Scenario 2: TestOps → MockServer
&lt;/h2&gt;

&lt;p&gt;We also have an MCP for MockServer, which makes another task possible:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Create a mock to verify this test case from TestOps.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Now the source is the test model, not the code. The agent reads the test case from TestOps, understands the scenario, and creates one or more mocks through the other MCP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TestOps → AI agent → MockServer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where the browser objection falls apart. If an MCP is only a way to click TestOps buttons on a human's behalf, the benefit is questionable. An agent with access to several systems solves a different problem: it carries &lt;strong&gt;meaning&lt;/strong&gt; between tools, not text between windows. One place describes what to check, another needs the environment prepared for that check, and the agent sees both.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                  ┌──────────────┐
                  │   AI agent   │
                  └──────┬───────┘
                         │
             ┌───────────┼───────────┐
             ↓           ↓           ↓
        Android code   TestOps   MockServer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Scenario 3: code ↔ TestOps audit
&lt;/h2&gt;

&lt;p&gt;There is a set of test cases in TestOps and a set of UI tests in the Android project. Periodically you need to know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;which test cases are already automated;&lt;/li&gt;
&lt;li&gt;which aren't yet;&lt;/li&gt;
&lt;li&gt;which UI tests have matching documentation;&lt;/li&gt;
&lt;li&gt;where the test changed in code but the description is stale.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By hand, that means opening two systems and matching entities one by one. The agent already has the source code; with the TestOps MCP it gets the other side. Now it can be trusted with the audit itself.&lt;/p&gt;

&lt;p&gt;This is not plain CRUD over an API. The value comes from the model reading two representations of the same process and finding correspondences between them. These are the tasks I now consider the most interesting for an MCP.&lt;/p&gt;

&lt;h2&gt;
  
  
  The server
&lt;/h2&gt;

&lt;p&gt;It's small: a standalone stdio MCP in Node.js with ten tools. You can search and read test cases, create and delete them, read and update scenarios, and work with issues and custom fields. It's set up for Cursor, Codex, and Claude.&lt;/p&gt;

&lt;p&gt;One TestOps API quirk had to be handled: scenarios can be stored in two formats, the old flat &lt;code&gt;/scenario&lt;/code&gt; format and the tree-shaped &lt;code&gt;/step&lt;/code&gt; format. The client handles both. Users of the MCP never see this, but without it the tool is unreliable on real data.&lt;/p&gt;

&lt;p&gt;The first implementation lived inside my own &lt;code&gt;meta-agent&lt;/code&gt;. But the tester uses Cursor, and making her deploy my whole &lt;code&gt;meta-agent&lt;/code&gt; just to reach TestOps made no sense. So the integration became a separate MCP server that can be connected directly to her agent. As long as a tool lives only in its author's environment, it solves the author's problem. Once a colleague can connect it to her agent, it becomes part of the team's workflow.&lt;/p&gt;

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

&lt;p&gt;I didn't write the MCP's code. A coding agent wrote 100% of it. My job was defining the task and checking the result, and in practice that meant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;deciding what was worth automating: not a web wrapper, script, or another dashboard on top of TestOps, but access to the entities the agent needs for end-to-end work;&lt;/li&gt;
&lt;li&gt;deciding which operations to expose;&lt;/li&gt;
&lt;li&gt;verifying them against a real TestOps instance;&lt;/li&gt;
&lt;li&gt;recognizing that the tool had to be separate from &lt;code&gt;meta-agent&lt;/code&gt; because a different person would use it in Cursor;&lt;/li&gt;
&lt;li&gt;framing the problem not as "the tester spends a lot of time in TestOps" but as: development has become agent-driven, while a human still manually connects the agent to the rest of the engineering infrastructure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The code turned out to be the relatively easy part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write access is a responsibility
&lt;/h2&gt;

&lt;p&gt;The more an agent can do on its own, the more carefully write operations need to be treated. The current scenario update in TestOps is not atomic. It deletes the existing scenario and then creates the new steps with sequential requests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DELETE current scenario
POST step 1
POST step 2
POST step 3
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no rollback. If the process is interrupted between the delete and the last write, a test case could theoretically end up with a partial or empty scenario. I don't have a confirmed incident of this kind of data loss, so I won't present it as something that happened, but the risk is in the code.&lt;/p&gt;

&lt;p&gt;An MCP does not turn an external service into a safe sandbox. If an agent is allowed to change data, its tools need to be designed like a normal production client: validation, tests, clear operation semantics, and protection against partial changes where possible.&lt;/p&gt;

&lt;p&gt;The standalone version currently has six tests, and they pass. Some write operations, such as changing issues/custom fields and deleting a test case, are not covered by dedicated tests yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  So why not just open the browser?
&lt;/h2&gt;

&lt;p&gt;I can. So can the tester. The MCP isn't there so a human never opens TestOps again. It's there so a human isn't a mandatory middleman in every operation between the agent and TestOps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;code → TestOps
TestOps → MockServer
code ↔ TestOps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each of these can be done by hand through a browser and an IDE. But if every transition between systems needs a human, agent-driven development stops at the IDE's border. What this project changed is that the human is no longer the transport protocol between code, the TMS, and the agent, and keeps the parts that are actually theirs: defining the task and checking the result.&lt;/p&gt;

&lt;p&gt;The server is open source: &lt;a href="https://github.com/hram/allure-testops-mcp" rel="noopener noreferrer"&gt;hram/allure-testops-mcp&lt;/a&gt;. The full case study is on my site: &lt;a href="https://hram.github.io/en/articles/allure-testops-mcp/" rel="noopener noreferrer"&gt;https://hram.github.io/en/articles/allure-testops-mcp/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>testing</category>
      <category>android</category>
    </item>
    <item>
      <title>What a Coding Agent Taught Me About A/B Test Telemetry</title>
      <dc:creator>Evgeny Khramov</dc:creator>
      <pubDate>Sun, 27 Sep 2026 09:33:46 +0000</pubDate>
      <link>https://dev.to/hram/what-a-coding-agent-taught-me-about-ab-test-telemetry-4md1</link>
      <guid>https://dev.to/hram/what-a-coding-agent-taught-me-about-ab-test-telemetry-4md1</guid>
      <description>&lt;p&gt;We had three designs for a new price-tag scanning screen in an Android app used by store staff. Claude Design produced the mockups, I showed them to colleagues and ran a vote. There was no clear winner.&lt;/p&gt;

&lt;p&gt;So I suggested the obvious thing: if we can't decide, let the users decide. A routine Android task turned into an A/B/C test.&lt;/p&gt;

&lt;p&gt;A coding agent wrote the new screen and all three layouts. That turned out to be the easy part, because I had never run an A/B test. I knew the idea from articles and videos: split users into groups, show different variants, collect metrics, compare. Between that description and a real experiment in production there were a lot of questions I couldn't answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which events should the app send, and with which parameters?&lt;/li&gt;
&lt;li&gt;How do you tell a normal scan attempt from a user who just closed the screen?&lt;/li&gt;
&lt;li&gt;How do you measure time?&lt;/li&gt;
&lt;li&gt;How do you tell "the code wasn't recognized because the user left" from "the app crashed"?&lt;/li&gt;
&lt;li&gt;How do you compare stores with very different scan volumes?&lt;/li&gt;
&lt;li&gt;And how do you analyze any of this afterwards?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Instead of reading another round of abstract examples, I went through the whole thing on the real task, with the agent. This post is about the parts you can reuse: the event model, the data layer, the traps in the Firebase to BigQuery export, and the one mistake no amount of SQL fixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  My first instinct: more actions, more events
&lt;/h2&gt;

&lt;p&gt;My first idea was simple: the more behavior you want to analyze, the more events you send. I started instrumenting a separate event for everything: opening the scanner, a successful scan, an error, a "move closer" hint, various user actions.&lt;/p&gt;

&lt;p&gt;The agent proposed a different model. For the experiment it mostly needed two events:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;barcode_scan_scanner_session_start
barcode_scan_scanner_session_finish
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference was in what each event carried.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;session_start
  session_id, variant (A/B/C), store, device, launch source, ...

session_finish
  session_id, result, code type, scan duration,
  "too far" hints, mismatches during double confirmation,
  focus / zoom / torch usage, decoded on first try, ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was the most useful thing I learned in the whole project, before a single number came in. I would not have designed it this way myself.&lt;/p&gt;

&lt;p&gt;I had thought of analytics as a list of events: something happens, you send an event. The agent effectively designed an entity, a &lt;strong&gt;scan session&lt;/strong&gt;, and the two events were just how the start and the outcome of that session got delivered. From then on, questions could be asked about a whole scan attempt instead of about individual clicks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Firebase collects the data; it's not where you analyze it
&lt;/h2&gt;

&lt;p&gt;Sending events to Firebase Analytics is easy. Analyzing this experiment in the Firebase console quickly isn't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;start and finish are separate events;&lt;/li&gt;
&lt;li&gt;parameters are nested inside &lt;code&gt;event_params&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;the two events have to be joined on &lt;code&gt;session_id&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;sessions with a start but no finish have to be counted;&lt;/li&gt;
&lt;li&gt;results need slicing by variant, store, business unit and device model;&lt;/li&gt;
&lt;li&gt;you want p50 and p75, not just averages;&lt;/li&gt;
&lt;li&gt;the raw daily and intraday tables must not produce duplicates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I had never worked with the Firebase Analytics export to BigQuery. The agent explained why the analysis needed it and then walked me through the setup: where to go, what to enable, what to click.&lt;/p&gt;

&lt;p&gt;The learning order was reversed. Normally it's documentation, then an example, then test data, then finally a real project. Here the real task, the real events and the real users came first, and I learned exactly as much BigQuery as the agent needed to keep going.&lt;/p&gt;

&lt;h2&gt;
  
  
  One row per session
&lt;/h2&gt;

&lt;p&gt;Once the export worked, the next problem appeared. Raw Firebase events are an awkward interface even for an agent. To answer "how many successful sessions did variant B have?" you have to unpack &lt;code&gt;event_params&lt;/code&gt;, find each start, find its finish, join them on &lt;code&gt;session_id&lt;/code&gt;, handle missing finishes, and only then compute the metric. Doing that in every query means repeating a large block of SQL over and over.&lt;/p&gt;

&lt;p&gt;The agent proposed a separate dataset, &lt;code&gt;scanner_ab&lt;/code&gt;, with a prepared table &lt;code&gt;scanner_ab.sessions&lt;/code&gt; in which &lt;strong&gt;one row is one scan session&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;Android app
  ↓
Firebase Analytics
  ↓
BigQuery events_*          (raw export)
  ↓
scanner_ab.sessions        (one row = one scan session)
  ↓
analysis queries
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where I understood why analytics people build prepared layers at all. Raw events stay raw. The prepared layer turns them into the business entity you actually ask questions about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The traps between "export enabled" and "numbers you can trust"
&lt;/h2&gt;

&lt;p&gt;This is how the pipeline actually works, including the details I would not have known to look for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reaching BigQuery from a sandboxed agent.&lt;/strong&gt; The agent runs in a sandbox whose network policy blocks the BigQuery API. Instead of fighting that, it goes through Cloud Shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud cloud-shell ssh &lt;span class="nt"&gt;--authorize-session&lt;/span&gt; &lt;span class="nt"&gt;--quiet&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--command&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"bq query --use_legacy_sql=false '&amp;lt;SQL&amp;gt;'"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Multi-line SQL with nested quotes is base64-encoded locally and decoded on the Cloud Shell side.&lt;/li&gt;
&lt;li&gt;The shell tool's timeout is raised to five minutes or more, otherwise long queries get cut off mid-run.&lt;/li&gt;
&lt;li&gt;Success is judged by reading stdout and stderr, not the exit code: &lt;code&gt;bq&lt;/code&gt; sometimes exits with 0 after an internal error.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;events_*&lt;/code&gt; also matches the intraday tables.&lt;/strong&gt; A wildcard &lt;code&gt;FROM events_*&lt;/code&gt; includes &lt;code&gt;events_intraday_YYYYMMDD&lt;/code&gt;, so on the day the daily table lands, the same events show up twice. Deduplicate by &lt;code&gt;session_id&lt;/code&gt;, or filter with &lt;code&gt;_TABLE_SUFFIX NOT LIKE 'intraday%'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The sessions table maintains itself.&lt;/strong&gt; It's updated by a &lt;code&gt;MERGE&lt;/code&gt; keyed on &lt;code&gt;session_id&lt;/code&gt; over a sliding window of &lt;code&gt;events_*&lt;/code&gt;, which also absorbs the intraday duplicates, and runs as a daily scheduled query. Analysis never touches the raw events.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The data disagreed with the documentation.&lt;/strong&gt; The experiment runbook said the launch parameter was &lt;code&gt;source&lt;/code&gt;; in the real events it's &lt;code&gt;extra_source&lt;/code&gt;. The runbook said a cancelled scan has &lt;code&gt;result = 'cancel'&lt;/code&gt;; the real value is &lt;code&gt;cancel_back&lt;/code&gt;. A query written from the docs returns zeros, with no error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every parameter arrived as a string.&lt;/strong&gt; Numeric fields need &lt;code&gt;SAFE_CAST&lt;/code&gt; before any arithmetic or quantiles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A metric can die silently.&lt;/strong&gt; &lt;code&gt;torch_used&lt;/code&gt; stopped meaning anything once the torch button was removed on devices without a flash unit. The field is still in the data; it just no longer says anything about lighting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Asking questions instead of writing SQL
&lt;/h2&gt;

&lt;p&gt;I now practically don't write these queries. I ask the agent in plain language:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Compare A/B/C for the last three days.&lt;/p&gt;

&lt;p&gt;Break the results down by business unit.&lt;/p&gt;

&lt;p&gt;Analyze by device model.&lt;/p&gt;

&lt;p&gt;In the analytics there's a drop. Compare it with Crashlytics.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The agent works out which data it needs, writes the SQL, runs it and returns a table. One sentence from me can turn into a long query: period filtering, session assembly, &lt;code&gt;UNNEST(event_params)&lt;/code&gt;, joining start and finish, checking for incomplete sessions, grouping by store and device, quantiles, guardrail metrics, cuts by variant. I couldn't write those queries without first learning BigQuery and analytical SQL properly.&lt;/p&gt;

&lt;p&gt;The result comes back as a table with roughly this shape (illustrative, not real numbers):&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;Sessions&lt;/th&gt;
&lt;th&gt;Success&lt;/th&gt;
&lt;th&gt;Cancel&lt;/th&gt;
&lt;th&gt;p75&lt;/th&gt;
&lt;th&gt;TooFar&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;…&lt;/td&gt;
&lt;td&gt;…&lt;/td&gt;
&lt;td&gt;…&lt;/td&gt;
&lt;td&gt;…&lt;/td&gt;
&lt;td&gt;…&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;…&lt;/td&gt;
&lt;td&gt;…&lt;/td&gt;
&lt;td&gt;…&lt;/td&gt;
&lt;td&gt;…&lt;/td&gt;
&lt;td&gt;…&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;…&lt;/td&gt;
&lt;td&gt;…&lt;/td&gt;
&lt;td&gt;…&lt;/td&gt;
&lt;td&gt;…&lt;/td&gt;
&lt;td&gt;…&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Then I ask the next question: "Now split B by tablet model", or "Is this a widespread problem or a few specific stores?"&lt;/p&gt;

&lt;p&gt;What makes this work isn't that an LLM can write SQL. LLMs have done that for a while. It's that the agent designed the data it was later going to analyze: it chose the telemetry, implemented it in the app, explained how to get raw events into BigQuery, proposed the prepared layer, and then started using that layer for its own queries.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mistake SQL can't fix: what is your unit of experiment?
&lt;/h2&gt;

&lt;p&gt;In our experiment, the variant is assigned to a &lt;strong&gt;store&lt;/strong&gt;, not to a person or a scan.&lt;/p&gt;

&lt;p&gt;That means a thousand scans in one store are not a thousand independent participants. If one large store scans far more than the others, you can't pour all sessions into one table and declare a winner. The analysis has to happen at the store level first, and only then can variants be compared.&lt;/p&gt;

&lt;p&gt;On top of that, stores differ in things the experiment doesn't control: tablet models, lighting, staff, load, local technical problems.&lt;/p&gt;

&lt;p&gt;The agent can find these limitations and build the right cuts. But the final conclusion can't become "the AI said B is better." Someone has to understand how the experiment was set up and what conclusion the data actually supports. Otherwise you get a very tidy table with the wrong meaning.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same telemetry turned out to be a diagnostic tool
&lt;/h2&gt;

&lt;p&gt;At one point the data showed a noticeable drop, and the question changed from "which variant wins?" to "why did the numbers get worse?"&lt;/p&gt;

&lt;p&gt;Because every attempt is a session, the same layer could answer that too: split the problem by store, by business unit, by device model; check whether it's one person, one tablet or a broad change; then line up the moment of degradation with Crashlytics. The pipeline built for a product experiment became part of a technical investigation.&lt;/p&gt;

&lt;p&gt;The session model helps here in a way separate events don't. Cancelling the scanner is an explicit result inside &lt;code&gt;session_finish&lt;/code&gt;. A session that has a start and no finish at all is a different signal, and that is the one worth checking against crash reports.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can reuse
&lt;/h2&gt;

&lt;p&gt;If you're instrumenting your first experiment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Model the attempt, not the clicks.&lt;/strong&gt; A &lt;code&gt;session_start&lt;/code&gt; / &lt;code&gt;session_finish&lt;/code&gt; pair with a shared &lt;code&gt;session_id&lt;/code&gt; and rich attributes gives you a complete record of each attempt. Many small events give you fragments you have to reassemble.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put the variant and the context in the start event.&lt;/strong&gt; Variant, store, device and launch source are what you'll slice by later. If they're not on the session, every analysis starts with a join you didn't plan for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make "no finish" distinguishable from "cancelled".&lt;/strong&gt; An explicit cancel result plus a missing-finish count separates users who gave up from sessions that never ended.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Export to BigQuery early&lt;/strong&gt; if you need joins, quantiles and slices. The Firebase console is for collecting, not for this kind of analysis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build one prepared table with one row per attempt&lt;/strong&gt; and point every question at it, instead of re-deriving sessions from raw events in each query.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the real parameter names and values before trusting any metric.&lt;/strong&gt; Documentation drifts; a wrong value doesn't fail, it just returns zero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch the &lt;code&gt;events_*&lt;/code&gt; wildcard.&lt;/strong&gt; It includes the intraday tables and double-counts the day the daily table arrives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide the unit of the experiment before reading any table.&lt;/strong&gt; If variants are assigned per store, analyze per store first.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The coding agent defined the events and their attributes, implemented the instrumentation in the Android app, explained why the analysis needed BigQuery and guided me through the export setup, proposed and built &lt;code&gt;scanner_ab.sessions&lt;/code&gt;, and now writes and runs the queries, checks data quality, builds the cuts and compares drops with Crashlytics.&lt;/p&gt;

&lt;p&gt;I brought the product question, asked the questions in plain language, and remain responsible for the part that doesn't come out of a query: how the experiment is set up and which conclusion the data actually allows.&lt;/p&gt;

&lt;p&gt;I didn't become a BigQuery specialist or an analyst. But I can now work on a task that previously would have required learning several new tools first. I don't work with BigQuery through SQL; I work with the agent in the language of engineering questions, and the agent works with BigQuery through SQL.&lt;/p&gt;

</description>
      <category>android</category>
      <category>firebase</category>
      <category>analytics</category>
      <category>ai</category>
    </item>
    <item>
      <title>One Master Prompt, Five Steps: How a Coding Agent Built a 4-Platform Kotlin App</title>
      <dc:creator>Evgeny Khramov</dc:creator>
      <pubDate>Sun, 27 Sep 2026 08:01:36 +0000</pubDate>
      <link>https://dev.to/hram/one-master-prompt-five-steps-how-a-coding-agent-built-a-4-platform-kotlin-app-3e4o</link>
      <guid>https://dev.to/hram/one-master-prompt-five-steps-how-a-coding-agent-built-a-4-platform-kotlin-app-3e4o</guid>
      <description>&lt;p&gt;I wanted a coding agent to build something with real breadth: a backend, a shared data layer and four client targets (Android, iOS, Desktop, Web) on one codebase, with sync, delivery and read receipts, push notifications and a self-hosted deploy to a plain VPS.&lt;/p&gt;

&lt;p&gt;That's not a CRUD app. In a normal team it would involve a backend developer, a mobile developer, someone for web, and an architecture discussion before anyone writes business logic.&lt;/p&gt;

&lt;p&gt;Generating code wasn't the part I wanted to test. I wanted to see whether a complex, multi-surface product can be planned well enough that an agent carries it out coherently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The project is public on GitHub, and so is every prompt used to build it&lt;/strong&gt;, unedited:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/hram/family-messenger/blob/main/promts/prompt.md" rel="noopener noreferrer"&gt;&lt;code&gt;prompt.md&lt;/code&gt;&lt;/a&gt;: the master prompt&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/hram/family-messenger/blob/main/promts/step_1.md" rel="noopener noreferrer"&gt;&lt;code&gt;step_1.md&lt;/code&gt;&lt;/a&gt; to &lt;a href="https://github.com/hram/family-messenger/blob/main/promts/step_5.md" rel="noopener noreferrer"&gt;&lt;code&gt;step_5.md&lt;/code&gt;&lt;/a&gt;: the five step prompts&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/hram/family-messenger" rel="noopener noreferrer"&gt;github.com/hram/family-messenger&lt;/a&gt;: the code they produced&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This post walks through what's in those files and why the work was split the way it was. Keep them open in another tab; the wording matters more than my summary of it.&lt;/p&gt;

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

&lt;p&gt;I described the idea to a web LLM (ChatGPT or Claude, I honestly don't remember which). It turned the idea into a large master prompt and then split it into five sequential step prompts. I then handed them one by one to a coding agent, which did the implementation. I didn't write the application code by hand.&lt;/p&gt;

&lt;p&gt;So the plan below was drafted by an LLM from my requirements, not typed out by me from scratch. My part was the idea, the constraints I cared about, moving each step to the agent, and checking what came back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with a single prompt
&lt;/h2&gt;

&lt;p&gt;"Build me a family messenger in Kotlin" produces &lt;em&gt;something&lt;/em&gt;. It doesn't reliably produce a system where the backend and four clients agree on the same DTOs, where sync survives a dropped connection, and where deployment is handled separately from application code.&lt;/p&gt;

&lt;p&gt;With this much scope, everything is decided at once: data contracts, backend behavior, client architecture and infrastructure. The approach here was to split the work into a &lt;strong&gt;contract&lt;/strong&gt; and a &lt;strong&gt;sequence&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The master prompt as an architecture contract
&lt;/h2&gt;

&lt;p&gt;The first file, &lt;a href="https://github.com/hram/family-messenger/blob/main/promts/prompt.md" rel="noopener noreferrer"&gt;&lt;code&gt;prompt.md&lt;/code&gt;&lt;/a&gt; (about 33 KB), isn't a feature request. It reads like a solution architect's document that fixes the boundaries before any code exists.&lt;/p&gt;

&lt;p&gt;A few of its constraints, translated from Russian:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- backend runs on an Ubuntu 24.04 VPS via Docker Compose, no Kubernetes
- no WebSocket; polling is the main way to get new messages
- FCM push is optional and Android-only; the system must work fully
  without it
- dependency injection on both backend and client goes through Koin
- a separate shared Kotlin module holds the request/response DTOs, enums
  and transport contracts, used by backend AND client, not duplicated
- closed registration by invite code only; no public sign-up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also specifies the REST API (12 endpoints, from &lt;code&gt;register-device&lt;/code&gt; to &lt;code&gt;messages/sync?since_id=...&lt;/code&gt;), a single &lt;code&gt;{success, data, error}&lt;/code&gt; response format, and a PostgreSQL schema with eight tables (&lt;code&gt;families&lt;/code&gt;, &lt;code&gt;users&lt;/code&gt;, &lt;code&gt;devices&lt;/code&gt;, &lt;code&gt;invites&lt;/code&gt;, &lt;code&gt;messages&lt;/code&gt;, &lt;code&gt;message_receipts&lt;/code&gt;, &lt;code&gt;location_events&lt;/code&gt;, &lt;code&gt;auth_tokens&lt;/code&gt;) and their fields.&lt;/p&gt;

&lt;p&gt;None of this is exotic. These are the decisions a team would normally make in a design review. Here they were written down once, before the agent touched the repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five steps, each with its own boundary
&lt;/h2&gt;

&lt;p&gt;The master prompt was then split into five step prompts. Each one names what it must &lt;em&gt;not&lt;/em&gt; do:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Focus&lt;/th&gt;
&lt;th&gt;Explicit constraint&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/hram/family-messenger/blob/main/promts/step_1.md" rel="noopener noreferrer"&gt;1&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Monorepo skeleton, shared-contract module, skeleton backend and client wired to it&lt;/td&gt;
&lt;td&gt;"Don't fully implement business logic yet; first build the right skeleton and shared contract."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/hram/family-messenger/blob/main/promts/step_2.md" rel="noopener noreferrer"&gt;2&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;The whole backend: routing, auth, Exposed persistence, all endpoints&lt;/td&gt;
&lt;td&gt;Use the shared-contract DTOs, no duplicates inside the backend, no TODO stubs instead of logic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/hram/family-messenger/blob/main/promts/step_3.md" rel="noopener noreferrer"&gt;3&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Infrastructure and deploy: Dockerfile, docker-compose, optional Caddy, deploy docs&lt;/td&gt;
&lt;td&gt;No Kubernetes, no complex DevOps stack; understandable to someone new to VPS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/hram/family-messenger/blob/main/promts/step_4.md" rel="noopener noreferrer"&gt;4&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;The whole client on Android, iOS, Desktop and Web: sync engine, local storage, UI&lt;/td&gt;
&lt;td&gt;Shared code must be substantial, not decorative; push strictly optional&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/hram/family-messenger/blob/main/promts/step_5.md" rel="noopener noreferrer"&gt;5&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Self-review as a principal engineer&lt;/td&gt;
&lt;td&gt;Check the existing project against the contract and fix what can be fixed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Step 1 stands out because it asks the agent to build &lt;em&gt;less&lt;/em&gt; than it could in one pass. The shared transport contract had to exist as a real Kotlin module, connected to both sides in the Gradle files, before either side had real logic. The backend and client were built on top of it afterwards, not side by side with separate models.&lt;/p&gt;

&lt;p&gt;Step 4 is where the "shared code must be substantial" rule becomes visible. The same panels are reused as a stack on mobile and as a split pane on desktop and web:&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%2Faenqx6ap599tyxsohrfv.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%2Faenqx6ap599tyxsohrfv.gif" alt="The Kotlin Multiplatform client switching between a mobile stack layout and a desktop split-pane layout" width="498" height="540"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: the step that doesn't add features
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/hram/family-messenger/blob/main/promts/step_5.md" rel="noopener noreferrer"&gt;&lt;code&gt;step_5.md&lt;/code&gt;&lt;/a&gt; asks for nothing new. It asks the agent to review its own output in eight areas, fix what it can directly in the code, and then list the remaining problems and production risks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. shared contract  - do backend and client really use the same DTOs?
2. backend API      - do the real endpoints match the documentation?
3. database/Exposed - are there enough indexes for sync and chat history?
4. infra            - does the backend actually start via Docker Compose?
5. KMP architecture - is commonMain substantial or decorative?
6. sync consistency - initial and incremental sync, retries, reconciliation
7. platform realism - can each target actually be launched?
8. security         - tokens, invite flow, family isolation, validation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the step that checks the distance between "the repository has 113 files" and "the repository does what the contract said." It's also the one that's easiest to drop once the first build works.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the plan produced
&lt;/h2&gt;

&lt;p&gt;The result was roughly 13,000 lines of Kotlin and Gradle across 113 files: a Ktor, Exposed and PostgreSQL backend, a Compose Multiplatform client on all four targets, a shared-contract module used by both, and a deploy to a real VPS. The first large working version took about three days.&lt;/p&gt;

&lt;p&gt;I want to be precise about what that proves. I didn't run a human team on the same spec in parallel, so this isn't a benchmark, and I'm not claiming it replaces a team. I spent three years at a company building mobile apps for clients, and my rough estimate for an MVP like this would be several specialists and at least a month. Treat that as one developer's gut feeling, not a measurement.&lt;/p&gt;

&lt;p&gt;It also wasn't a finished product after step 5. I used it for about a week on real devices, including my kids' phones, with real push notifications. That's where the limits showed. Message statuses (&lt;code&gt;LOCAL_PENDING → SENT → DELIVERED → READ&lt;/code&gt;) look simple on paper. In practice the unread counters drifted once several devices were involved. The git history shows the follow-up fixes: authorization, deployment, FCM, long polling, a stale sync cursor, behavior on network errors.&lt;/p&gt;

&lt;p&gt;A good plan got me a coherent and large first version. It didn't replace testing against real multi-device use.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can reuse
&lt;/h2&gt;

&lt;p&gt;If you're about to give a coding agent something wider than a single service:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Write the constraints before the features.&lt;/strong&gt; The useful part of the master prompt isn't "build a messenger." It's "no WebSocket, polling, Koin on both sides, one shared DTO module." These are architectural decisions you make yourself instead of leaving them to the agent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put the shared contract in its own first step, without business logic.&lt;/strong&gt; Then both sides are built against the same compiled models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give infrastructure its own step&lt;/strong&gt; instead of tucking it into "build the backend."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;End with a self-review against your own contract&lt;/strong&gt; as a separate step. Use the eight areas above or your own list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the parts only real use can test.&lt;/strong&gt; Multi-device and multi-user flows are where a coherent-looking implementation can still disagree with itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To adapt this to your own project, start from the real files rather than this summary: &lt;a href="https://github.com/hram/family-messenger/tree/main/promts" rel="noopener noreferrer"&gt;the prompts folder&lt;/a&gt; and &lt;a href="https://github.com/hram/family-messenger" rel="noopener noreferrer"&gt;the full repository&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>kotlin</category>
      <category>architecture</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>My AI Agent Wrote All the Code for an Orienteering Analyzer. The Hard Part Came After It Worked.</title>
      <dc:creator>Evgeny Khramov</dc:creator>
      <pubDate>Fri, 25 Sep 2026 18:47:32 +0000</pubDate>
      <link>https://dev.to/hram/my-ai-agent-wrote-all-the-code-for-an-orienteering-analyzer-the-hard-part-came-after-it-worked-594h</link>
      <guid>https://dev.to/hram/my-ai-agent-wrote-all-the-code-for-an-orienteering-analyzer-the-hard-part-came-after-it-worked-594h</guid>
      <description>&lt;p&gt;My daughter has an orienteering competition almost every week. When she finishes, the result is already known: her place, her total time, the protocol. For me, that is only the start of the analysis. What I want to know is &lt;strong&gt;where exactly the time was lost, why it happened, and whether the same problem repeats from start to start&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I used to use another service for this. It showed some of the data, but I couldn't georeference the map properly, mark control points on it, or automatically cut a GPS track into splits. Most importantly, I couldn't upload a competition protocol and compare each leg with the other participants.&lt;/p&gt;

&lt;p&gt;So I had plenty of data and no answer to a simple question: what exactly should she work on?&lt;/p&gt;

&lt;p&gt;That is why I built a &lt;a href="https://github.com/hram/orienteering" rel="noopener noreferrer"&gt;portal for analyzing orienteering&lt;/a&gt;. It has one more unusual property: an AI agent wrote all of its code. I never saw a single line of the source. I set tasks, opened the result in a browser, checked it on real competitions, and formulated the next task.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;

&lt;p&gt;Three inputs that live in different worlds have to end up in one model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;scanned map (an image)     → georeferenced map → digitized course (start, CPs, finish)
GPX track from the watch   → cut into splits: start → CP1 → CP2 → … → finish
official race protocol     → best time per leg → "Ideal Leader" benchmark
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A quick glossary: a control point (CP) is a checkpoint on the course, and a split is the time for one leg between two consecutive CPs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: turn the map into coordinates
&lt;/h2&gt;

&lt;p&gt;An orienteering map is essentially a photo or scan of a sheet with the course printed on it. A GPS track is in geographic coordinates. To combine them, the map has to be georeferenced.&lt;/p&gt;

&lt;p&gt;In the portal I upload the image, then mark at least three corresponding points on the orienteering map and on an ordinary base map. Internally the portal uses an affine transformation, calculated from those reference points, to place the whole image. After that I click the start, every CP, and the finish on the image, and each click is converted into geographic coordinates.&lt;/p&gt;

&lt;p&gt;The input was a picture. The output is a digital route with a sequence of CPs.&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%2Fqgikd2z8pgx228vwcvav.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%2Fqgikd2z8pgx228vwcvav.png" alt="Digitizing an orienteering map: the original scan, the overlay on a base map, and the sequence of control points" width="799" height="370"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Left: the original scan. Middle: the overlay on the base map. Right: the sequence of control points. The interface is in Russian.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: cut the GPX into splits
&lt;/h2&gt;

&lt;p&gt;Once the portal knows the coordinates of every CP, a GPX upload doesn't need any manual explanation of where one leg ends and the next begins. For each control point, the portal looks for the place where the track passes closest to its coordinates, and cuts the track at those points: start → CP1, CP1 → CP2, and so on to the finish.&lt;/p&gt;

&lt;p&gt;This is where raw GPS points become something you can treat as a sports result. Each leg has a start, an end, a time, the length of the actual track, and a pace.&lt;/p&gt;

&lt;p&gt;GPS is not perfect. At some starts a point can drift, and the moment of passing a CP is not always detected correctly. So the portal keeps manual correction of the markers: the automation does most of the work, and a person fixes the places where the real data is messy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: compare every leg with the field
&lt;/h2&gt;

&lt;p&gt;The official protocol is what tells me how well or badly each individual section went. Importing protocols turned out to be less tidy than I wanted. There is no single format. Over time the portal learned to parse several sources and variants of HTML, JSON, and PDF, and then separate cases appeared: multi-day starts, relays, other competition formats. A change on an external website can still break a particular importer. That is the price of integrating with other people's formats.&lt;/p&gt;

&lt;p&gt;After the import, the portal takes each split separately and finds the participant with the best time on it. Those results are assembled into a synthetic row I call the &lt;strong&gt;"Ideal Leader"&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It is not a real athlete. One person may have been fastest on the first leg, another on the second, a third on the third. The Ideal Leader is put together from the best splits actually posted across the whole course.&lt;/p&gt;

&lt;p&gt;I prefer this to comparing with the winner, who may have made a mistake somewhere too. Every leg gets a local reference: the best time anyone actually posted on it. In the protocol I can see at once where the gap is small and where the main loss happened. Good and problematic sections are colored, and every cell opens a detailed analysis of that split.&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%2Fldej2c2sov4dbcbb56r6.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%2Fldej2c2sov4dbcbb56r6.png" alt="A race protocol with splits: the " width="800" height="371"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The "Ideal Leader" collects the best time on each leg from different participants. Athletes' names are hidden. The interface is in Russian.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: zoom in on one problem leg
&lt;/h2&gt;

&lt;p&gt;At the level of the whole run, the portal only highlights good and bad splits. A red split does not mean the program knows the cause of a mistake. It means: &lt;em&gt;this is a section worth looking into&lt;/em&gt;. That already narrows the search. If a run lasted over an hour, I don't have to hunt manually for the few interesting minutes.&lt;/p&gt;

&lt;p&gt;Opening a specific split changes the screen. The portal cuts out only the part of the map that belongs to the selected leg and shows the actual GPS track plus a straight line between the two CPs. It also draws a pace chart for this leg only.&lt;/p&gt;

&lt;p&gt;Take an example where the straight line between two controls is 300 meters and the actual track is 820. That alone does not prove a mistake: in orienteering you can't draw a straight line through a swamp, a fence, or an impassable area. But a gap like that immediately makes you look carefully at the route choice.&lt;/p&gt;

&lt;p&gt;On this screen I can also record the cause of the problem.&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%2Fnn1sczpjd9q1l2po8aa6.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%2Fnn1sczpjd9q1l2po8aa6.png" alt="Analysis of a single split: the map, the actual track, the pace chart, and the AI coach's answer" width="800" height="476"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One split: the map and track show the route choice, the chart shows the pace, and the AI coach gives a short analysis. The interface and the AI coach's reply are in Russian.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What the AI assistant gets
&lt;/h3&gt;

&lt;p&gt;For the same split I can launch an AI assistant. It does not receive the whole competition database or a sentence like "the fifth control went badly." The portal prepares the context itself: a PNG of exactly the part of the map shown on screen, with the track and the control points, the numeric parameters of the split, and a prompt. Then Claude is launched through the CLI and has to look at the section, try to identify problems in how it was run, and give recommendations.&lt;/p&gt;

&lt;p&gt;What interests me is the context preparation more than the button. The portal has already selected the split, discarded the rest of the course, and prepared the map, the pace, the distances, and the gap. The AI receives an almost ready-made statement of a specific problem.&lt;/p&gt;

&lt;p&gt;I don't consider this part solved. The AI can offer an explanation and recommendations, but whether they &lt;strong&gt;actually help change the training process and reduce such losses at future starts&lt;/strong&gt; is a different question. The portal has not proven that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: from single splits to statistics
&lt;/h2&gt;

&lt;p&gt;One bad split can be an accident. So the main screen doesn't look at one run. On the left, the portal keeps a queue of problem splits still worth analyzing: sections with a noticeable gap and low pace. On the right, the causes of the problems I have already analyzed accumulate.&lt;/p&gt;

&lt;p&gt;That gives information of a different level: not "there was a mistake on the fifth split last Sunday," but "this type of problem repeats more often than the others."&lt;/p&gt;

&lt;p&gt;For example, the top problem in the current data can turn out to be not a navigation error but "low pace without a mistake." That is a very different conversation. If someone chooses the route correctly, doesn't lose the map, and doesn't drift off course, but systematically loses on pace, the next training hypothesis is nothing like the one for constant direction errors.&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%2Fj9l60ha4ndbm1m5b7zh6.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%2Fj9l60ha4ndbm1m5b7zh6.png" alt="The error reasons dashboard: the queue of problem splits and the frequency of recurring causes" width="800" height="315"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Once enough splits are analyzed, individual notes turn into statistics of recurring causes. Athletes' names are hidden. The interface is in Russian.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What "the AI wrote 100% of the code" meant in practice
&lt;/h2&gt;

&lt;p&gt;I am a developer, but on this project I deliberately did not work as one in the usual sense. I did not open the source and did not do code review. My work looked different: I saw a problem in real use and set a task, the agent implemented it, and I opened the portal, loaded real data, went through the user scenario, and checked whether my problem was solved. If not, I formulated the next task.&lt;/p&gt;

&lt;p&gt;That is how georeferencing, GPX splitting, protocol import, split comparison, the analysis card, error causes, the dashboard, and the AI assistant appeared, step by step.&lt;/p&gt;

&lt;p&gt;My acceptance criterion was not the code but a working product. The technical side was not left completely unchecked: the repository has automated tests, and on the state of the project I checked, a full run gave &lt;code&gt;109 passed&lt;/code&gt;. But I evaluated the system from the outside, as a user who knows what result is needed.&lt;/p&gt;

&lt;p&gt;If the agent takes over writing the code entirely, the main question is no longer "how do I implement this?" but &lt;strong&gt;"what should be implemented next?"&lt;/strong&gt; The agent did not answer that for me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem that appeared once it worked
&lt;/h2&gt;

&lt;p&gt;The portal now turns an image of a map into a digital course, cuts the GPX into splits, pulls in the official protocol, builds the Ideal Leader, shows where time was lost, helps classify the cause, and collects statistics on recurring problems.&lt;/p&gt;

&lt;p&gt;Right after that I ran into a question I had barely noticed at the start: what do I do with the problem once I've found it?&lt;/p&gt;

&lt;p&gt;Say the portal shows the main recurring factor is low pace without a navigation error. We know that. But what training action follows from it? Or the AI looked at several problem splits and gave recommendations. How do I tell whether they are right? How do I turn them into a specific exercise? How do I check, a few weeks later, that this particular problem occurs less often?&lt;/p&gt;

&lt;p&gt;I don't have a good answer yet. At first I thought the main job was to collect data and find mistakes. That part is increasingly automated, and the difficulty is moving further along: from diagnosis to changing behavior and the training process.&lt;/p&gt;

&lt;p&gt;I also can't say the portal has made my daughter's results better. I don't have the data for that conclusion. What I can say is that instead of a general assessment of a result, my family now has a concrete conversation about specific sections of the course.&lt;/p&gt;

&lt;p&gt;The portal is open source: &lt;a href="https://github.com/hram/orienteering" rel="noopener noreferrer"&gt;orienteering on GitHub&lt;/a&gt;. The full case study is on my site: &lt;a href="https://hram.github.io/en/articles/orienteering/" rel="noopener noreferrer"&gt;hram.github.io/en/articles/orienteering&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>sideprojects</category>
      <category>orienteering</category>
    </item>
  </channel>
</rss>
