<?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: Panth Patel</title>
    <description>The latest articles on DEV Community by Panth Patel (@panthpatel).</description>
    <link>https://dev.to/panthpatel</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%2F4143534%2F3561e361-6ed6-41fc-8bad-811d1a0a9b77.png</url>
      <title>DEV Community: Panth Patel</title>
      <link>https://dev.to/panthpatel</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/panthpatel"/>
    <language>en</language>
    <item>
      <title>[Boost]</title>
      <dc:creator>Panth Patel</dc:creator>
      <pubDate>Fri, 06 Nov 2026 03:02:47 +0000</pubDate>
      <link>https://dev.to/panthpatel/-83i</link>
      <guid>https://dev.to/panthpatel/-83i</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/dannwaneri/dont-write-markdown-files-for-your-coding-agent-write-scripts-j00" class="crayons-story__hidden-navigation-link"&gt;Don't Write Markdown Files for Your Coding Agent. Write Scripts.&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
      &lt;a href="https://dev.to/dannwaneri/dont-write-markdown-files-for-your-coding-agent-write-scripts-j00" class="crayons-article__context-note crayons-article__context-note__feed"&gt;&lt;p&gt;Comments warn agents modifying their own scripts&lt;/p&gt;

&lt;/a&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/dannwaneri" class="crayons-avatar  crayons-avatar--l  "&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%2Fuser%2Fprofile_image%2F3606168%2Fa45537f0-d879-4611-a026-3fb74fcddb24.jpg" alt="dannwaneri profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/dannwaneri" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Daniel Nwaneri
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Daniel Nwaneri
                &lt;a href="/++"&gt;&lt;img alt="Subscriber" class="subscription-icon" src="https://assets.dev.to/assets/subscription-icon-805dfa7ac7dd660f07ed8d654877270825b07a92a03841aa99a1093bd00431b2.png"&gt;&lt;/a&gt;
                
              
              &lt;div id="story-author-preview-content-4948394" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/dannwaneri" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&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%2Fuser%2Fprofile_image%2F3606168%2Fa45537f0-d879-4611-a026-3fb74fcddb24.jpg" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Daniel Nwaneri&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/dannwaneri/dont-write-markdown-files-for-your-coding-agent-write-scripts-j00" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Nov 5&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/dannwaneri/dont-write-markdown-files-for-your-coding-agent-write-scripts-j00" id="article-link-4948394"&gt;
          Don't Write Markdown Files for Your Coding Agent. Write Scripts.
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/ai"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;ai&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/programming"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;programming&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/devops"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;devops&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/productivity"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;productivity&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/dannwaneri/dont-write-markdown-files-for-your-coding-agent-write-scripts-j00" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/fire-f60e7a582391810302117f987b22a8ef04a2fe0df7e3258a5f49332df1cec71e.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;50&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;span class="crayons-story__favorited"&gt;
              &lt;span class="favorited-marker"&gt;
                &lt;span&gt;
                  

                &lt;/span&gt;
                &lt;span class="hidden"&gt;
                  

                &lt;/span&gt;
              &lt;/span&gt;
            &lt;/span&gt;
            &lt;a href="https://dev.to/dannwaneri/dont-write-markdown-files-for-your-coding-agent-write-scripts-j00#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              32&lt;span class="hidden s:inline"&gt;&amp;nbsp;comments&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            4 min read
          &lt;/small&gt;
          
          &lt;div class="only-sidebar-menu-item trusted-visible-block print-hidden align-center"&gt;
                
                    
                
          &lt;/div&gt;

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

&lt;/div&gt;


</description>
    </item>
    <item>
      <title>Three ways into a client's on-prem server, and where the client's switch sits</title>
      <dc:creator>Panth Patel</dc:creator>
      <pubDate>Thu, 05 Nov 2026 13:01:05 +0000</pubDate>
      <link>https://dev.to/panthpatel/three-ways-into-a-clients-on-prem-server-and-where-the-clients-switch-sits-1dj7</link>
      <guid>https://dev.to/panthpatel/three-ways-into-a-clients-on-prem-server-and-where-the-clients-switch-sits-1dj7</guid>
      <description>&lt;p&gt;A government client's server is not my server. On-Prem Manager (OPM) can now reach one in three ways, so the useful question for each is who can turn it off.&lt;/p&gt;

&lt;p&gt;OPM is the open-source tool I built to run on-premise Docker deployments: a cloud console, and one Go agent per server with its own GUI. Three things changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The browser tunnel is end-to-end encrypted
&lt;/h2&gt;

&lt;p&gt;Some sites have no internet at all. For those, the agent borrows the browser of the person using its GUI: the tab fetches from the cloud on the agent's behalf.&lt;/p&gt;

&lt;p&gt;Until this change, every request and response passed through that tab in clear. That included commits with secret values, and on a LAN the agent GUI may be plain http.&lt;/p&gt;

&lt;p&gt;Now the agent seals each request to an X25519 key on the cloud, and that key has to be certified by a release key pinned in the agent's config. The cloud answers with an AES-256-GCM stream. The tab carries ciphertext.&lt;/p&gt;

&lt;p&gt;There is no fallback. If the key is missing or the signature is wrong, the call fails and the reason shows on the Internet page. An agent with no pinned key can be told to run in clear, and that is logged every time a tunnel starts.&lt;/p&gt;

&lt;p&gt;The security review changed the design once. A replayed request gets a second answer under the same key, and with the plain design that reuses key and nonces, which opens the first answer. So each response now starts with a random 32-byte salt that derives its own key.&lt;/p&gt;

&lt;p&gt;The tab can still see that a call happened, its size and its timing, and it can drop calls. Relays between agents on the mesh are signed, not encrypted. Agents older than this stay in clear until they are updated and have the release key, which the install script adds and &lt;code&gt;opm-agent upgrade&lt;/code&gt; does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The agent GUI on the cloud's own domain
&lt;/h2&gt;

&lt;p&gt;Each site's agent GUI now opens from the cloud console, at a path under OPM's own domain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;cloud&amp;gt;/app/deployments/{deploymentId}/remote/{siteSlug}/_opm/…
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You log in to the cloud once. There is no agent key to find and no extra port for the client to open, because the site asks the cloud for work over outbound HTTPS and the cloud never calls a site. Every action is recorded on the site under the person's cloud email, and the GUI shows "through the cloud".&lt;/p&gt;

&lt;p&gt;This one is on by default. The client switches it off in the agent GUI on the site, and open remote sessions end at once. A remote session can't flip that switch; the call answers 403.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Port tunnels
&lt;/h2&gt;

&lt;p&gt;A container port, the site's edge or a TCP port on the server can be forwarded to a port on the cloud's VM. It carries raw TCP, so a website, an API or a database client all work, and a site with no cloud route of its own can be served through a peer.&lt;/p&gt;

&lt;p&gt;You add a tunnel in the console, and it does nothing there. It stays off until someone switches it on in that site's agent GUI, under Agent › Tunnels. Each site keeps its own switches.&lt;/p&gt;

&lt;p&gt;Two limits, because the switch is only as good as what sits around it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The tunnel switch also works from the agent GUI opened through the cloud. So a client who wants the decision to stay on their side should turn the remote GUI off as well.&lt;/li&gt;
&lt;li&gt;The public port has no login of its own. Whoever reaches it reaches the target, so the target's own auth is what stands there.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why the switches sit on the client's side
&lt;/h2&gt;

&lt;p&gt;Local deployment is why these clients buy Envizom in the first place. They keep the data, and now they also decide how much of a path to it exists. If a client isn't comfortable with any of this, the same agent GUI turns it off and the deployment keeps running without it.&lt;/p&gt;

&lt;p&gt;The code is at &lt;a href="https://github.com/Panth977/on-premise-manager" rel="noopener noreferrer"&gt;github.com/Panth977/on-premise-manager&lt;/a&gt;, Apache-2.0. The sealing is in SPEC-final §9.10, the two tunnels in §21 #118 and #125.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>security</category>
      <category>docker</category>
      <category>opensource</category>
    </item>
    <item>
      <title>A kanban board for AI coding agents: 12 stages, 6 repos</title>
      <dc:creator>Panth Patel</dc:creator>
      <pubDate>Tue, 03 Nov 2026 13:01:05 +0000</pubDate>
      <link>https://dev.to/panthpatel/a-kanban-board-for-ai-coding-agents-12-stages-6-repos-44kn</link>
      <guid>https://dev.to/panthpatel/a-kanban-board-for-ai-coding-agents-12-stages-6-repos-44kn</guid>
      <description>&lt;p&gt;12 board stages, 6 repos per ticket, about 5,100 lines of TypeScript, 2 days to build. The first full ticket went from Todo to merged across all 6 repos for 0.8M tokens.&lt;/p&gt;

&lt;p&gt;That is Orch2, the development pipeline we run on Envizom at Oizom, where I'm a Tech Lead. One change usually touches a Go backend, a Svelte frontend, a UI library and 3 driver repos. Orch2 turns a kanban board into the control panel: a plain script runs the pipeline, Claude agents only do the coding, and every ticket gets its own sandboxed copy of the whole product. The Envizom code is proprietary, so this is the design, not the source.&lt;/p&gt;

&lt;p&gt;Three things stay separate. The tracker is a product. Orch2 is a client of it. The agent is what Orch2 runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  A script, not an agent
&lt;/h2&gt;

&lt;p&gt;The orchestrator is ordinary TypeScript: predictable, cheap and easy to debug. An LLM starts only when there is code to write or a conflict to resolve. The board is the whole UI. There are no dashboards and no CLI for users, just stages, the thread and question cards, and orch replies in the thread with links, PRs and preview URLs. A human types one field, Src Branch. The ticket branch is the ticket key in every repo, and everything else is derived.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Who&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Todo&lt;/td&gt;
&lt;td&gt;human&lt;/td&gt;
&lt;td&gt;backlog, orch never acts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pull Latest&lt;/td&gt;
&lt;td&gt;script&lt;/td&gt;
&lt;td&gt;branch in all 6 repos, workspace built, stack health-checked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Approved&lt;/td&gt;
&lt;td&gt;script&lt;/td&gt;
&lt;td&gt;agent started or resumed on the ticket branch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Working&lt;/td&gt;
&lt;td&gt;agent&lt;/td&gt;
&lt;td&gt;posts a plan as a task list, codes, tests against its live stack, calls &lt;code&gt;endWork&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Review&lt;/td&gt;
&lt;td&gt;human&lt;/td&gt;
&lt;td&gt;opens the running app and the browser IDE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Git Push&lt;/td&gt;
&lt;td&gt;script&lt;/td&gt;
&lt;td&gt;commit, merge base, build check, push, one PR per changed repo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Preview&lt;/td&gt;
&lt;td&gt;script&lt;/td&gt;
&lt;td&gt;production build, HTTPS link posted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Confirm&lt;/td&gt;
&lt;td&gt;human&lt;/td&gt;
&lt;td&gt;the lead checks the preview&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Git Merge&lt;/td&gt;
&lt;td&gt;script&lt;/td&gt;
&lt;td&gt;every PR checked first, then merged libraries-first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Completed&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;done&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cancel&lt;/td&gt;
&lt;td&gt;human&lt;/td&gt;
&lt;td&gt;Yes/No card, stops the agent, closes the PRs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trashed&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;branch, workspace and preview deleted&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every agent turn posts a short receipt and adds its token use to a board aggregate, so cost shows per ticket on the card and per day in analytics. The aggregate is stored in millions because 798,400 on a card was unreadable. It now says 0.8.&lt;/p&gt;

&lt;p&gt;Each developer runs their own orch, with its own board agent and token, so assigning that agent to a ticket hands the ticket to that machine. The same codebase has a &lt;code&gt;preview&lt;/code&gt; mode that runs once, on the dev server, for production previews.&lt;/p&gt;

&lt;h2&gt;
  
  
  A copy of the product per ticket
&lt;/h2&gt;

&lt;p&gt;A workspace is 6 repo clones, its own data and its own stack on its own block of ports. Git uses a bare mirror of each repo and clones with &lt;code&gt;--reference --dissociate&lt;/code&gt;, so a checkout takes seconds and owns its objects. GitHub auth goes per command as an HTTP header, so no credential is written to disk in a workspace.&lt;/p&gt;

&lt;p&gt;One front router on a single port routes by hostname: &lt;code&gt;&amp;lt;ticket&amp;gt;.localhost&lt;/code&gt; for the app and &lt;code&gt;code-&amp;lt;ticket&amp;gt;.localhost&lt;/code&gt; for &lt;a href="https://github.com/Panth977/code-shell" rel="noopener noreferrer"&gt;code-shell&lt;/a&gt;, the browser IDE. Browsers resolve &lt;code&gt;*.localhost&lt;/code&gt; themselves, so a new ticket needs no DNS record and no proxy reload.&lt;/p&gt;

&lt;p&gt;Data is real and safely copied. SQLite comes through a token-gated read-only file server as a &lt;code&gt;VACUUM INTO&lt;/code&gt; snapshot, because copying a live WAL database gives a corrupt file. Downloads are checked by magic bytes, so an HTML error page can never be saved as a database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five layers around the agent
&lt;/h2&gt;

&lt;p&gt;A ticket agent can run commands, edit, build and test. It can't push, reach secrets or touch anything outside its ticket. A gap in one layer is caught by the next:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Scrubbed environment.&lt;/strong&gt; Every secret removed, for the agent, the workspace stack and the IDE.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OS sandbox on every shell command.&lt;/strong&gt; Seatbelt on macOS, bubblewrap on Linux. Writes only in the workspace and build caches, network limited to localhost, package registries and GitHub.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A guard on every tool call.&lt;/strong&gt; The same policy twice, in the SDK permission callback and a PreToolUse hook. It blocks &lt;code&gt;git push&lt;/code&gt;, branch switches, &lt;code&gt;reset --hard&lt;/code&gt;, &lt;code&gt;ssh&lt;/code&gt; and piping downloads into a shell, orch-managed files are read-only, and every denial returns the reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A narrow tool surface.&lt;/strong&gt; Only orch's own MCP server: ticket and thread, plan, stack control, &lt;code&gt;endWork&lt;/code&gt;. No connectors, and no web fetch because it could leak ticket text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gates.&lt;/strong&gt; &lt;code&gt;endWork&lt;/code&gt; is refused until the plan is finished, the stack is healthy and the merge is clean. Orch commits and pushes only after a human moves the card to Git Push.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What production taught us
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;160k tokens lost by one agent fighting a failed Go module download&lt;/td&gt;
&lt;td&gt;network build failures go to a human; agents get only code problems&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Corporate TLS inspection broke Go proxy and GitHub calls at random&lt;/td&gt;
&lt;td&gt;quiet retries at 1, 3 and 9 minutes before anything is posted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fast clones failed with "unable to parse commit" on new git versions&lt;/td&gt;
&lt;td&gt;commit-graph off on clone and mirror fetch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A production build ran out of memory and hung a swapless 15 GB server&lt;/td&gt;
&lt;td&gt;one preview at a time, container memory limit, heap cap, free-memory check&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A preview stack died after a container restart&lt;/td&gt;
&lt;td&gt;10-minute watchdog; startup reconciles every ticket&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first row decided a design rule: infra failures go to a person, never an agent, because an agent can't fix a firewall and will burn tokens trying.&lt;/p&gt;

&lt;p&gt;What in your pipeline still lets an agent spend tokens on a problem it can't solve?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>typescript</category>
      <category>agents</category>
    </item>
    <item>
      <title>Release-based planning: only the release gets a deadline</title>
      <dc:creator>Panth Patel</dc:creator>
      <pubDate>Mon, 02 Nov 2026 13:00:46 +0000</pubDate>
      <link>https://dev.to/panthpatel/release-based-planning-only-the-release-gets-a-deadline-37hc</link>
      <guid>https://dev.to/panthpatel/release-based-planning-only-the-release-gets-a-deadline-37hc</guid>
      <description>&lt;p&gt;For my first 6 months as a Tech Lead I did not know how to say no, and every deadline got pushed. My answer was two shifts: we plan releases instead of features, and the product document replaced the codebase as the source of truth.&lt;/p&gt;

&lt;p&gt;I'm Panth Patel, Tech Lead on Envizom at Oizom. This post is about process, not code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the job turned out to be
&lt;/h2&gt;

&lt;p&gt;I started as a backend developer. I could debug anything in the stack, and I was comfortable helping with product design and the roadmap. None of that prepared me for a product that sells to clients.&lt;/p&gt;

&lt;p&gt;Some features were wanted right now or the client wouldn't buy. Others were insistent suggestions that could sit in the backlog for months before anyone asked for an update. There was no cap on how many came in. As a dev you think everything is doable, until you realise you are no longer a proof of concept and people expect professional experience.&lt;/p&gt;

&lt;p&gt;And I kept running into one question. If you expect no bugs and a stable release, how can you commit to a client on a 1-week timeline?&lt;/p&gt;

&lt;p&gt;We were selling almost everything, about 80-90% of what clients asked for, and the team was praised for it many times. But every deadline slipped, and every release took new features at the last moment. Clients expected a stable system while we still acted like a startup. To scale, Envizom had to become a separate product, with client customisation in a proper framework instead of production code changes.&lt;/p&gt;

&lt;p&gt;So I spent 2 months reading how professionals run this: how they scale, how they set KRAs and KPIs, how big tech manages a codebase with frequent releases. Some of it was too much for us and some too little. What we ended up with is good enough, and it works like a machine: each part has one job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shift 1: the document changes first
&lt;/h2&gt;

&lt;p&gt;Every feature now starts as a 5-10 minute group discussion, without waiting for a standup. It defines the exact change to the product document before anyone implements anything. Product development happens there, and execution doesn't. If the feature needs research, a separate R&amp;amp;D task starts and blocks it.&lt;/p&gt;

&lt;p&gt;Then the feature doc goes to three people at once:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;Now&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lead&lt;/td&gt;
&lt;td&gt;came in only if needed&lt;/td&gt;
&lt;td&gt;estimates from the doc&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dev&lt;/td&gt;
&lt;td&gt;built it, then explained it to the product owner and QA/QC&lt;/td&gt;
&lt;td&gt;implements and tests their own work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;QA/QC&lt;/td&gt;
&lt;td&gt;driven by the dev&lt;/td&gt;
&lt;td&gt;writes cases from the doc, runs them, attaches a report&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row is the one I care about most. QA/QC reads only the latest feature doc, so a bug gets caught the way a client would hit it. Before this, the product truth always ended up being the code.&lt;/p&gt;

&lt;p&gt;Each task also attaches two short videos: one explaining the product, one explaining the development interfaces and contract. The product owner and dev lead confirm the original clarifications were met, and only then does it go to QA/QC.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shift 2: features have no deadlines
&lt;/h2&gt;

&lt;p&gt;Nobody asks whether a feature is "immediate" any more, and nothing is "4 days" or "1 month". A feature goes into a release, and no work happens until a release is defined. A feature can join a release only when no R&amp;amp;D is left, just implementation. Only the release has a deadline and an estimated dev time.&lt;/p&gt;

&lt;p&gt;The calendar has rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;At least 2 days of code freeze per release.&lt;/li&gt;
&lt;li&gt;1 extra QA/QC day after the release.&lt;/li&gt;
&lt;li&gt;Releases happen on working days, with at least 2 working days ahead and no consecutive holidays.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Git follows the release too. Each release has a dev branch and a prod branch. Every feature and bug-fix branch comes off the release's dev branch and merges back to it. The prod branch has two environments: demo, recently cloned from the prod DB and running the upcoming release, and prod itself, updated on release date.&lt;/p&gt;

&lt;p&gt;The comms come first as well. Change logs, product updates and press releases are defined at the start of release development and go out to client-facing colleagues, devs, QA/QC and leads.&lt;/p&gt;

&lt;p&gt;Where in your team does a feature get its deadline: when someone asks for it, or when it joins a release?&lt;/p&gt;

</description>
      <category>leadership</category>
      <category>productivity</category>
      <category>management</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>[Boost]</title>
      <dc:creator>Panth Patel</dc:creator>
      <pubDate>Tue, 27 Oct 2026 03:02:34 +0000</pubDate>
      <link>https://dev.to/panthpatel/-1n2j</link>
      <guid>https://dev.to/panthpatel/-1n2j</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/gde/postmortem-the-tuesday-our-ai-bill-had-no-owner-34pd" class="crayons-story__hidden-navigation-link"&gt;Postmortem: The Tuesday Our AI Bill Had No Owner&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
      &lt;a href="https://dev.to/gde/postmortem-the-tuesday-our-ai-bill-had-no-owner-34pd" class="crayons-article__context-note crayons-article__context-note__feed"&gt;&lt;p&gt;Unlabeled tokens create hidden retry loops&lt;/p&gt;

&lt;/a&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;
          &lt;a class="crayons-logo crayons-logo--l" href="/gde"&gt;
            &lt;img alt="Google Developer Experts logo" 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%2Forganization%2Fprofile_image%2F11939%2Fe3080d5b-ecde-42a8-b089-bafecc31fa97.png" class="crayons-logo__image" width="800" height="800"&gt;
          &lt;/a&gt;

          &lt;a href="/karthidec" class="crayons-avatar  crayons-avatar--s absolute -right-2 -bottom-2 border-solid border-2 border-base-inverted  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fthepracticaldev.s3.amazonaws.com%2Fi%2F99mvlsfu5tfj9m7ku25d.png" alt="karthidec profile" class="crayons-avatar__image" width="800" height="800"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/karthidec" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Karthigayan Devan
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Karthigayan Devan
                
                
              
              &lt;div id="story-author-preview-content-4915028" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/karthidec" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fthepracticaldev.s3.amazonaws.com%2Fi%2F99mvlsfu5tfj9m7ku25d.png" class="crayons-avatar__image" alt="" width="800" height="800"&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Karthigayan Devan&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

            &lt;span&gt;
              &lt;span class="crayons-story__tertiary fw-normal"&gt; for &lt;/span&gt;&lt;a href="/gde" class="crayons-story__secondary fw-medium"&gt;Google Developer Experts&lt;/a&gt;
            &lt;/span&gt;
          &lt;/div&gt;
          &lt;a href="https://dev.to/gde/postmortem-the-tuesday-our-ai-bill-had-no-owner-34pd" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Oct 25&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/gde/postmortem-the-tuesday-our-ai-bill-had-no-owner-34pd" id="article-link-4915028"&gt;
          Postmortem: The Tuesday Our AI Bill Had No Owner
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag crayons-tag--filled  " href="/t/discuss"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;discuss&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/vertexai"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;vertexai&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/sre"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;sre&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/ai"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;ai&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/gde/postmortem-the-tuesday-our-ai-bill-had-no-owner-34pd" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/exploding-head-daceb38d627e6ae9b730f36a1e390fca556a4289d5a41abb2c35068ad3e2c4b5.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;14&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/gde/postmortem-the-tuesday-our-ai-bill-had-no-owner-34pd#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              8&lt;span class="hidden s:inline"&gt;&amp;nbsp;comments&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            14 min read
          &lt;/small&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
      <category>management</category>
      <category>productivity</category>
      <category>startup</category>
    </item>
    <item>
      <title>944 lines that turn a ticket into a headless Claude Code session</title>
      <dc:creator>Panth Patel</dc:creator>
      <pubDate>Mon, 26 Oct 2026 13:00:30 +0000</pubDate>
      <link>https://dev.to/panthpatel/944-lines-that-turn-a-ticket-into-a-headless-claude-code-session-4aag</link>
      <guid>https://dev.to/panthpatel/944-lines-that-turn-a-ticket-into-a-headless-claude-code-session-4aag</guid>
      <description>&lt;p&gt;I hand work to Claude Code through tickets, not a terminal. A 944-line orchestrator with zero dependencies turns each ticket into its own headless Claude Code session, and on 27 September I open-sourced it together with the tracker it works in: &lt;a href="https://github.com/Panth977/ticket-tracker" rel="noopener noreferrer"&gt;ticket-tracker&lt;/a&gt;, MIT.&lt;/p&gt;

&lt;p&gt;I'm Panth, and I lead the software team at Oizom. I dropped out before the AI wave, because I figured the engineering was in solving problems in the real world, and my college made me choose between attendance and real-world applications. This post is about how the orchestrator works and why it is shaped the way it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why tickets and not a terminal
&lt;/h2&gt;

&lt;p&gt;I wanted to give my VM work from my phone, while travelling or waiting in a queue, with no laptop. The first version was a terminal in an app. It grew into a room full of agents, and three things went wrong:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;I got attached to one long-running agent, and starting fresh ones was a chore.&lt;/li&gt;
&lt;li&gt;The app was a door into the VM. If someone broke in, I would spend a long time cleaning up.&lt;/li&gt;
&lt;li&gt;When the VM lost internet, I couldn't even queue a prompt.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Tickets fixed all three. Every ticket gets its own session, so there is no single agent to get attached to. The orchestrator runs on the machine and pulls work from the tracker, so nothing has to connect into that machine. The tracker is hosted apart from it, so I can raise tickets while the VM is offline, and my VM and my PC can take work from the same board.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loop
&lt;/h2&gt;

&lt;p&gt;An agent is a board member: a profile, a system prompt and a token that acts as that agent on one board. Assign a ticket to it and move the ticket to To do. From there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;To do  ──orch claims──▶ In progress ──agent reports "review"──▶ Review ──owner──▶ Done
                            │  ▲
           agent asks a     │  │ the answer (form or a comment) resumes
           question ────────▼  │ the same session
                          waiting
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The orchestrator claims the ticket and starts &lt;code&gt;claude -p&lt;/code&gt; in the repo the workspace points at, with the tracker's MCP tools. The agent reads the ticket and its thread, then publishes its plan as a task list on the ticket and marks each item as it goes.&lt;/p&gt;

&lt;p&gt;When it needs a decision, it calls &lt;code&gt;ask_question&lt;/code&gt;, which puts a form card in the thread, and ends its turn with &lt;code&gt;status: "waiting"&lt;/code&gt;. It does not poll. The answer, or any comment in the thread, starts the next turn with &lt;code&gt;--resume&lt;/code&gt; on the same session, so the agent picks up with its whole history. Moving the ticket back to To do resumes it as rework. Unassigning it, or moving it anywhere else while the agent runs, stops the agent.&lt;/p&gt;

&lt;p&gt;The brief every agent gets says it in two lines:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You run headless in [the repo] and nobody is watching your terminal. The owner only sees the ticket, so everything you want them to see goes on the ticket.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The orchestrator owns the ticket's state
&lt;/h2&gt;

&lt;p&gt;The agent gets the tracker's MCP tools, minus the ones that decide where the ticket stands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// What only the orch may do: the stage, the heartbeat, the inbox, assignment.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DISALLOWED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;move_ticket&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;update_ticket&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assign_ticket&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;create_ticket&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;heartbeat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;get_events&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ack_events&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`mcp__tm__&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent does the work and reports &lt;code&gt;review&lt;/code&gt;, &lt;code&gt;waiting&lt;/code&gt; or &lt;code&gt;blocked&lt;/code&gt;. The orchestrator moves the ticket, beats, reads the event inbox and posts the receipt. So a ticket only reaches Review after the orchestrator has seen the agent report it done.&lt;/p&gt;

&lt;p&gt;The receipt is one message per turn with what the turn cost. Claude Code's &lt;code&gt;total_cost_usd&lt;/code&gt; is cumulative across &lt;code&gt;--resume&lt;/code&gt;, so the orchestrator stores the last total and posts the difference. Because the agent pushes the cost of each turn, you can see where the money actually went — which ticket, which turn — instead of one number at the end of the month. One ticket of mine reached $402 over nine runs before the app showed any of it; the full story of that fix is in &lt;a href="https://panth.vardayinitech.in/blog/cost-per-turn-headless-claude-code" rel="noopener noreferrer"&gt;$402 on one ticket&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Health from a heartbeat
&lt;/h2&gt;

&lt;p&gt;The orchestrator beats once a minute while an agent runs, and every fifth beat it re-reads the task list, so the ticket's dot shows progress like &lt;code&gt;3/12 · Running tests&lt;/code&gt;. The board never stores "this agent is dead". It derives health from the last status and the time since the last beat, in one function, recomputed every 15 seconds:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Health&lt;/th&gt;
&lt;th&gt;When&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;working&lt;/td&gt;
&lt;td&gt;state is working and the last beat is under 75 s old&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stale&lt;/td&gt;
&lt;td&gt;state is working and no beat for 75 s: the orchestrator may have died&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;idle&lt;/td&gt;
&lt;td&gt;waiting for an answer, or stopped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;done&lt;/td&gt;
&lt;td&gt;finished, ready for review&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;error&lt;/td&gt;
&lt;td&gt;stopped with an error, for example a timeout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;no status yet&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;75 seconds is one missed beat plus a little slack. Only "working" can go stale: idle, done and error are statements that stay true until the next beat. After five minutes of silence the agent's owner gets one notification per silence.&lt;/p&gt;

&lt;h2&gt;
  
  
  One command layer
&lt;/h2&gt;

&lt;p&gt;The app, the REST API (&lt;code&gt;/v1&lt;/code&gt;, OpenAPI 3.1) and the MCP server (&lt;code&gt;/mcp&lt;/code&gt;) all run the same commands. Nothing an agent can do is different from what a person can do, and every change is attributed, for example "Owner via Claude (MCP)". Tokens are scoped to one board and act either as you or as one of your agents.&lt;/p&gt;

&lt;h2&gt;
  
  
  What running it takes
&lt;/h2&gt;

&lt;p&gt;Everything runs on one Firebase project: Firestore, Functions, Storage, Realtime Database, Auth and Hosting. "Self-hosted" here means your own Firebase project on the Blaze plan, not a Docker image. Locally, &lt;code&gt;pnpm dev&lt;/code&gt; starts the emulators, seeds demo data and serves the app (Node 22, pnpm 9, Java 21 and the Firebase CLI).&lt;/p&gt;

&lt;p&gt;Firebase, because the tracker had to live apart from the machines doing the work: if the VM is offline or the agents are down, I can still raise tickets. From there the app does live reads straight from Firestore, Auth and push come with it, and the orchestrator wakes on a Realtime Database revision stream, so when an agent comes back it picks up where it left off.&lt;/p&gt;

&lt;p&gt;A workspace is a folder with &lt;code&gt;workspace.json&lt;/code&gt; (repo, board, agent, how many agents at once), a &lt;code&gt;.env&lt;/code&gt; with a Worker token for that agent, and &lt;code&gt;brief.md&lt;/code&gt; with the project's rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node workspaces/ws.mjs new myproject &lt;span class="nt"&gt;--repo&lt;/span&gt; ~/src/myproject &lt;span class="nt"&gt;--board&lt;/span&gt; MYP &lt;span class="nt"&gt;--agent&lt;/span&gt; &lt;span class="s2"&gt;"Claude MyProject"&lt;/span&gt;
&lt;span class="c"&gt;# mint a Worker token for that agent in the app (Account › Tokens), put it in workspaces/myproject/.env&lt;/span&gt;
node workspaces/ws.mjs up
node workspaces/ws.mjs status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Agents run unattended in your working tree, with &lt;code&gt;permissionMode&lt;/code&gt; from &lt;code&gt;workspace.json&lt;/code&gt;: &lt;code&gt;acceptEdits&lt;/code&gt; in the template, &lt;code&gt;bypassPermissions&lt;/code&gt; only if you trust the brief and the repo. Read &lt;code&gt;workspaces/lib/brief.md&lt;/code&gt; before you enable one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is not in it
&lt;/h2&gt;

&lt;p&gt;At work I've written about per-ticket stacks: a branch in every repo, its own services, its own copy of the dev database and a preview URL per ticket. None of that is in this repo. That code is specific to Envizom and proprietary. Here the agent works in the repo you point it at.&lt;/p&gt;

&lt;p&gt;The repo is &lt;a href="https://github.com/Panth977/ticket-tracker" rel="noopener noreferrer"&gt;github.com/Panth977/ticket-tracker&lt;/a&gt;; &lt;code&gt;workspaces/README.md&lt;/code&gt; has the loop.&lt;/p&gt;

&lt;p&gt;If you run Claude Code headless, where does its question go when it needs a decision, and who answers it?&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>opensource</category>
      <category>ai</category>
      <category>claudecode</category>
    </item>
    <item>
      <title>Replacing Node-RED with a 10 MB browser editor and a start.sh</title>
      <dc:creator>Panth Patel</dc:creator>
      <pubDate>Sun, 25 Oct 2026 13:01:09 +0000</pubDate>
      <link>https://dev.to/panthpatel/replacing-node-red-with-a-10-mb-browser-editor-and-a-startsh-1mh0</link>
      <guid>https://dev.to/panthpatel/replacing-node-red-with-a-10-mb-browser-editor-and-a-startsh-1mh0</guid>
      <description>&lt;p&gt;A client disconnected our server from the internet for security, and the integration for that client still had to be written on it.&lt;/p&gt;

&lt;p&gt;I'm Panth, and I lead the software team at Oizom, where we build Envizom, an air quality platform that many clients run on their own servers. Every one of those deployments needs a bit of custom work: pull data from somewhere, or expose an API in a schema the client gives us for a public display. That work can't go into our production code. This post is about where it lives now: &lt;a href="https://github.com/Panth977/code-shell" rel="noopener noreferrer"&gt;codeshell&lt;/a&gt;, a small browser workspace I built and open-sourced under Apache-2.0.&lt;/p&gt;

&lt;h2&gt;
  
  
  Node-RED first
&lt;/h2&gt;

&lt;p&gt;Node-RED was the default for these integrations before I joined. It is fragile for this kind of work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An async for loop is hard to write.&lt;/li&gt;
&lt;li&gt;Five API calls with something equivalent to &lt;code&gt;Promise.all&lt;/code&gt; is close to rocket science.&lt;/li&gt;
&lt;li&gt;Debugging is the worst part.&lt;/li&gt;
&lt;li&gt;If there is a JavaScript library for the job but no Node-RED node in the marketplace, you spend five hours figuring out how to set the library up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are short programs. Plain code is the right tool for them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then one repo per deployment
&lt;/h2&gt;

&lt;p&gt;The next idea: each deployment gets its own codebase, built into one Docker container that holds all of that server's customisations.&lt;/p&gt;

&lt;p&gt;It works until the client disconnects the server from the internet. Then the integration still has to be written, so you open a shell in the container, edit the code with &lt;code&gt;vi&lt;/code&gt;, and hope nobody pulls the latest image before you have copied the same code back into the repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually needed
&lt;/h2&gt;

&lt;p&gt;A VS Code-like editor in the browser, on the server, with the code directory mounted from outside so it survives a restart. If the code lives in one volume, and the volume is backed up, the integration doesn't need a repo at all.&lt;/p&gt;

&lt;p&gt;VS Code in the browser is heavy for this, because the language servers and compilers run on the server. I looked for a light editor and didn't find one, so I wrote codeshell.&lt;/p&gt;

&lt;h2&gt;
  
  
  What codeshell is
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Part&lt;/th&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Backend&lt;/td&gt;
&lt;td&gt;Go, one ~10 MB static binary with the UI embedded. It serves plain files and PTYs. No language servers, no compilers.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frontend&lt;/td&gt;
&lt;td&gt;Svelte 5, CodeMirror 6 for the editor, xterm.js for terminals. Syntax modes load per file type.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/code&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The one required volume. Root of the file tree, working directory of every terminal.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/code/start.sh&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Runs on every container start, restart and recreate.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Size&lt;/td&gt;
&lt;td&gt;about 1,750 lines of Go and 3,700 of Svelte and TypeScript&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Running one integration is one container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; myapp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:8080 &lt;span class="nt"&gt;-p&lt;/span&gt; 3000:3000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;CODESHELL_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;change-me &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; myapp-code:/code &lt;span class="nt"&gt;-v&lt;/span&gt; myapp-state:/var/lib/codeshell &lt;span class="se"&gt;\&lt;/span&gt;
  ghcr.io/panth977/code-shell:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Port 8080 is the editor. Port 3000 is whatever the integration listens on.&lt;/p&gt;

&lt;h2&gt;
  
  
  start.sh is the deploy
&lt;/h2&gt;

&lt;p&gt;Terminals are temporary on purpose. Anything you install or start from a terminal is gone after a restart. What has to come back goes in &lt;code&gt;start.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt;
&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; node &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; nodejs npm&lt;span class="o"&gt;)&lt;/span&gt;
npm ci
&lt;span class="nb"&gt;exec &lt;/span&gt;node index.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The image is Debian slim, so &lt;code&gt;start.sh&lt;/code&gt; can &lt;code&gt;apt-get install&lt;/code&gt; whatever runtime the integration needs. On the first run it installs Node and the modules. On later runs &lt;code&gt;node_modules&lt;/code&gt; is already in the volume, and mounting &lt;code&gt;/root&lt;/code&gt; and &lt;code&gt;/opt&lt;/code&gt; as volumes too keeps package caches and runtimes, so restarts get faster.&lt;/p&gt;

&lt;p&gt;codeshell supervises the script. Its output shows as a &lt;code&gt;start.sh&lt;/code&gt; entry in the terminal panel, next to your own terminals, with Restart and Stop buttons, and its state is in the top bar: running, stopped, exited, waiting or missing. The restart policy is &lt;code&gt;on-failure&lt;/code&gt; by default, and restarts back off from 1 second, doubling, to a cap of 60 seconds. On a fresh volume it writes a commented &lt;code&gt;start.sh&lt;/code&gt; template with a Node and a Python example.&lt;/p&gt;

&lt;p&gt;Terminals keep their scrollback on the server, so a page reload or a dropped connection doesn't lose them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it sits
&lt;/h2&gt;

&lt;p&gt;The other half of our on-premise work is &lt;a href="https://panth.vardayinitech.in/blog/on-prem-manager-docker-deployments" rel="noopener noreferrer"&gt;On-Prem Manager&lt;/a&gt;, which deploys and updates the containers on each client server and snapshots their volumes. With codeshell, a client's integration is one container and one volume. On-Prem Manager can persist that volume's content to its cloud, where it is versioned and reviewed, so the integration needs no repo of its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't try to be
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Single user. One login, an HMAC-signed session cookie, and repeated failed logins from one IP are locked out for longer each time.&lt;/li&gt;
&lt;li&gt;Anyone who signs in has a root shell in the container. Put it behind HTTPS and use a strong password. The password and the session secret are removed from the environment of terminals and &lt;code&gt;start.sh&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;No IntelliSense. It's an editor with syntax highlighting, quick open, split groups, find and replace, and a terminal.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The repo is &lt;a href="https://github.com/Panth977/code-shell" rel="noopener noreferrer"&gt;github.com/Panth977/code-shell&lt;/a&gt;. Images are published for &lt;code&gt;linux/amd64&lt;/code&gt; and &lt;code&gt;linux/arm64&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Where do your per-client integrations live today, and what happens to them when the client's server goes offline?&lt;/p&gt;

</description>
      <category>docker</category>
      <category>go</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
    <item>
      <title>90/10: what we took away from our AI coding agent, and why</title>
      <dc:creator>Panth Patel</dc:creator>
      <pubDate>Sat, 24 Oct 2026 13:01:16 +0000</pubDate>
      <link>https://dev.to/panthpatel/9010-what-we-took-away-from-our-ai-coding-agent-and-why-179g</link>
      <guid>https://dev.to/panthpatel/9010-what-we-took-away-from-our-ai-coding-agent-and-why-179g</guid>
      <description>&lt;p&gt;The first version of our agent pipeline let the coding agent run the git and deploy steps. It didn't fail often, but when it did, it failed in a different way each time, and I couldn't debug that. Today roughly 90% of the pipeline is plain scripted code, and the agent does one job.&lt;/p&gt;

&lt;p&gt;I'm Panth, and I lead the software team at Oizom, where Envizom is our environmental monitoring platform. Two days ago I wrote &lt;a href="https://panth.vardayinitech.in/blog/agent-pipeline-90-percent-scripts" rel="noopener noreferrer"&gt;the short version&lt;/a&gt;: the eight steps that run after a developer drags a ticket to Git Push. This is the long one. It follows one ticket through every column of the board and goes through each job we took off the agent, with the reason, then what it costs and what broke.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first version
&lt;/h2&gt;

&lt;p&gt;Before agents, our problems were ordinary ones. Envizom is several repos: hardware drivers, a simulator, a backend and a frontend. A new developer needed days to get a working local setup. Releases meant keeping track of which dev database, prod database, backend and frontend were pointed at each other, and as time went on I was the only person who knew every combination.&lt;/p&gt;

&lt;p&gt;Coding agents made my developers faster. With an agent, one developer can keep three tasks in flight. The mistakes then moved out of the code and into the branches around it. A change landed on the wrong branch, or a fix only worked on one machine. I wrote about that in &lt;a href="https://panth.vardayinitech.in/blog/one-stack-per-ticket" rel="noopener noreferrer"&gt;One stack per ticket&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In the first version of the pipeline, the agent did more of the operational steps. It did the engineering, and it also ran git and the deploy. Compared with what we run now, it was slower and more expensive, and it was worse at the tasks that need to be boring.&lt;/p&gt;

&lt;p&gt;The failures taught me the most. A script that fails the same way twice is a bug you can fix: you read the log, find the line and change it. An agent that merges a little differently on each ticket gives you nothing to fix. Each failure is a new story, and the next run will tell another one.&lt;/p&gt;

&lt;p&gt;The split we ended up with follows one line. If a step is the same on every ticket, it is a script. The agent keeps the engineering.&lt;/p&gt;

&lt;h2&gt;
  
  
  One ticket, end to end
&lt;/h2&gt;

&lt;p&gt;The board has seven columns. One of them has the agent in it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Who acts&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Feedback&lt;/td&gt;
&lt;td&gt;developer&lt;/td&gt;
&lt;td&gt;writes what they want, in plain English. That is the whole spec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Approved&lt;/td&gt;
&lt;td&gt;scripts&lt;/td&gt;
&lt;td&gt;dragging the ticket here is the handoff. The orchestrator provisions the ticket's stack&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Working&lt;/td&gt;
&lt;td&gt;agent&lt;/td&gt;
&lt;td&gt;does the engineering work inside that stack, and talks only through the ticket thread&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;QAQC&lt;/td&gt;
&lt;td&gt;developer&lt;/td&gt;
&lt;td&gt;opens the preview the agent posted, tests it, replies in the thread or moves it on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Git Push&lt;/td&gt;
&lt;td&gt;scripts&lt;/td&gt;
&lt;td&gt;commit, merge dev in, open and merge a pull request per repo, post the links&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VM Update&lt;/td&gt;
&lt;td&gt;scripts&lt;/td&gt;
&lt;td&gt;wait for CI, roll the new images onto the dev VM, health checks, rollback if unhealthy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Completed&lt;/td&gt;
&lt;td&gt;scripts&lt;/td&gt;
&lt;td&gt;the developer gets a notification, and the ticket's stack is torn down&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each developer has their own devbox on our dev VM: VS Code in the browser, with our orchestrator, orch, inside it. Every developer is paired with their own agent, and their orch only picks up tickets assigned to that agent. The board is shared. The machines and the agents are not.&lt;/p&gt;

&lt;p&gt;Here is a real ticket from the walkthrough video. I asked for a camera layout change on a device page. The agent built it in the ticket's stack and posted the preview. I replied in the thread: "also handle more than 4 cameras with a horizontal scroll, and set up 8 test cameras on this device". The ticket went back to the agent, which picked up with the context it already had, did both, and created the 8 test cameras in the ticket's own copy of the database. I tested the new preview and dragged the ticket to Git Push. After that, two notifications, and the change was live on dev.&lt;/p&gt;

&lt;p&gt;The person in that story touched the ticket three times: to write it, to reply to a preview, and to drag it. The agent had one column. Scripts ran the other four.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ten jobs scripts own
&lt;/h2&gt;

&lt;p&gt;This is the list no agent touches: watching the board, provisioning the stack, copying the database, issuing certificates, driving git, opening pull requests, waiting on CI, deploying, health checks and rollback. They fall into two groups, before the agent starts and after a person says yes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Before the agent starts
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Watching the board.&lt;/strong&gt; Orch watches for tickets dragged to Approved and assigned to its agent. Nothing here needs any thought. A ticket is either in the column or it isn't, and a model would add cost and nothing else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Provisioning the stack.&lt;/strong&gt; Every approved ticket gets a fresh branch in every repo, named after the ticket, plus its own backend, drivers, simulator and frontend. This is the step everything else depends on, so it has to be the same every time. If the stack came out a little different per ticket, nothing built on top of it could be trusted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Copying the database.&lt;/strong&gt; Each ticket gets a copy of the dev database, so it can change data freely. This is the safety decision in the whole design. An agent that runs a bad migration or deletes the wrong rows does it to the ticket's copy. Nobody else's work is in that box, so nobody else's work is touched. A step that sets up the safety net shouldn't be done by the thing the net is for.&lt;/p&gt;

&lt;p&gt;In Envizom V3, which we are rebuilding from scratch, the copy is cheap. Every store has a dev driver. Postgres, S3, the time-series database and Redis all become SQLite in dev, and MQTT becomes a service built into the Go backend. SQLite runs inside the Go process, so copying a database means copying a few files. That change made our sandboxes about 10x cheaper and faster than sandboxes with full databases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Issuing certificates.&lt;/strong&gt; Every ticket has a private preview URL with an HTTPS certificate issued on demand. That URL is what the developer tests in QAQC, so it has to exist and work before the agent posts it.&lt;/p&gt;

&lt;h3&gt;
  
  
  After a person says yes
&lt;/h3&gt;

&lt;p&gt;When the developer drags a ticket to Git Push, no agent is involved from that moment on. Orch runs eight steps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Git Push
  1  commit the work on the ticket branches
  2  merge the current dev branch into them
  3  open a pull request in each affected repo
  4  merge the pull requests
  5  post the PR and commit links into the ticket thread
VM Update
  6  wait for CI
  7  roll the new images onto the dev VM
  8  run health checks against the VM
       unhealthy -&amp;gt; roll back to the previous images, revert the merges,
                    tell the thread what happened
       healthy   -&amp;gt; Completed, notify the developer, tear the stack down
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Driving git and opening pull requests.&lt;/strong&gt; One of our goals from the start was that using agents would not weaken the normal process. Every feature still gets its own branch, commits, pull request and review. That only holds if those steps happen the same way on every ticket. An agent deciding how to merge is not a feature. A script that always merges the same way is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Posting the links.&lt;/strong&gt; Step 5 looks small, but it is how the thread becomes the audit trail. The PR and commit links land next to the ask, the previews and the feedback, so one ticket holds the whole story of one change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Waiting on CI.&lt;/strong&gt; Waiting is the clearest case of a job a model shouldn't hold. Nothing gets decided while it waits, and an agent session kept alive just to wait is still a session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deploying, health checks and rollback.&lt;/strong&gt; These are the steps where sameness matters most. If the VM is unhealthy after the deploy, orch rolls it back to the previous images, reverts the merges and tells the thread, without anyone asking. A rollback that improvises is the last thing you want on the day something breaks. A scripted one runs the way it ran on every ticket before.&lt;/p&gt;

&lt;p&gt;All of it stops at the dev environment. Auto-merge, the VM update and the rollback apply to dev only. Production goes through a separate path, where people make the call on a slower cadence. That is on purpose. We don't want automation deciding what reaches production, and we don't want a fast feature list to become the goal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Taking away "I'm done"
&lt;/h2&gt;

&lt;p&gt;The same split shows up in &lt;a href="https://github.com/Panth977/ticket-tracker" rel="noopener noreferrer"&gt;ticket-tracker&lt;/a&gt;, the board I open-sourced, where a small orchestrator turns each ticket into a headless Claude Code session. (The per-ticket stack scripts are not in it. They are Envizom-specific code.) It takes a few more jobs off the agent, ones that are easy to miss because they don't look like jobs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deciding it is finished.&lt;/strong&gt; The agent never moves its own ticket. Its last message is structured output, enforced with &lt;code&gt;--json-schema&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;OUTCOME_SCHEMA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;review&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;waiting&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blocked&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;question_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;summary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The orchestrator reads it. &lt;code&gt;review&lt;/code&gt; moves the ticket to Review. &lt;code&gt;waiting&lt;/code&gt; means a person is needed, so nothing runs until someone answers. &lt;code&gt;blocked&lt;/code&gt; usually means the turn ran out of room, so the orchestrator resumes it by itself, up to 3 times, and then says on the ticket that it has stopped. The agent reports. The script decides what that means for the board.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Saying it is alive.&lt;/strong&gt; The heartbeat comes from the orchestrator, not the agent: one beat every 60 seconds while the agent works. A crashed process can't report that it crashed, so health is worked out from the age of the last beat. The agent's part is its task list, which the orchestrator reads for the status message. More in &lt;a href="https://panth.vardayinitech.in/blog/agent-heartbeat-derived-health" rel="noopener noreferrer"&gt;Agent health from a 60-second heartbeat&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Working out what a turn cost.&lt;/strong&gt; When a run ends, the orchestrator reads Claude Code's result line and posts the turn's receipt on the ticket. The app's comment box never posts one. Each receipt goes out with an idempotency key made from the ticket and the run number, so a retried request doesn't count a turn twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Waiting for an answer.&lt;/strong&gt; When the agent needs a decision, it asks in the ticket and ends its turn. It is told not to poll. My answer starts the same session again with &lt;code&gt;--resume&lt;/code&gt;. &lt;a href="https://panth.vardayinitech.in/blog/questions-end-the-turn" rel="noopener noreferrer"&gt;Headless Claude Code asks, exits, then resumes on the answer&lt;/a&gt; has the details.&lt;/p&gt;

&lt;p&gt;Any of these could be left to the agent. Each one is the same on every ticket, so none of them is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the agent still does
&lt;/h2&gt;

&lt;p&gt;The engineering. It reads the ticket, works in the ticket's own repos and services, changes the code, and creates the test data it needs in the ticket's database copy, as in the 8 test cameras. When the developer replies with feedback, it picks up with the same context it had before, without re-reading the repo to find out where it was.&lt;/p&gt;

&lt;p&gt;It also asks. A real decision that belongs to the owner, such as scope, design, or anything destructive or outward-facing, becomes a question in the ticket rather than a guess.&lt;/p&gt;

&lt;p&gt;That is the 10%. It is the only part of the pipeline where the right answer changes from ticket to ticket, so it is the only part where a model is worth what it costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What stays with people
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Writing the ticket.&lt;/strong&gt; The thread is the only way to talk to the agent, so every prompt is stored with the work it produced. I read how each person on the team asks for things and coach them on it. That has done more than any prompting guide. See &lt;a href="https://panth.vardayinitech.in/blog/prompts-stored-in-the-ticket" rel="noopener noreferrer"&gt;Our coding agent prompts live in the ticket&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;QA.&lt;/strong&gt; The agent posts a preview and moves the ticket to QAQC, and that is as far as it goes. The developer who asked tests the preview, because they are the only one who knows what they meant. An agent grading its own work would remove the one review that matters most. Every step after QA is built to be boring and repeatable, and none of them asks whether the change was the right change. QA is where that question gets asked, so it is the one place we didn't automate. More in &lt;a href="https://panth.vardayinitech.in/blog/qa-stays-human" rel="noopener noreferrer"&gt;1 human check, 8 scripted steps&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The drag to Git Push.&lt;/strong&gt; Shipping to dev is a person's action in the thread. Shipping to production is a separate, slower decision.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Number&lt;/th&gt;
&lt;th&gt;Source of the number&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One agent turn in the walkthrough video&lt;/td&gt;
&lt;td&gt;$4.77&lt;/td&gt;
&lt;td&gt;the receipt under the agent's reply: "turn $4.77 · total $4.77"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The dev VM with every devbox and ticket stack&lt;/td&gt;
&lt;td&gt;about ₹4–6k a month&lt;/td&gt;
&lt;td&gt;rented, one machine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tickets it runs in parallel&lt;/td&gt;
&lt;td&gt;6, comfortably&lt;/td&gt;
&lt;td&gt;same VM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sandbox cost after SQLite dev drivers&lt;/td&gt;
&lt;td&gt;about 10x cheaper and faster&lt;/td&gt;
&lt;td&gt;V3 rebuild&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A scripted step&lt;/td&gt;
&lt;td&gt;no turn receipt&lt;/td&gt;
&lt;td&gt;it is not a model call&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row is the plain reason scripts are cheaper. Every agent turn posts its cost into the ticket thread, right under the answer. A scripted merge, deploy or health check posts no receipt, because it doesn't call a model. An operational step done by the agent is done inside a billed turn.&lt;/p&gt;

&lt;p&gt;The receipt is a design choice too. The person who asked sees what the ask cost, where they asked it, and it changes how they write the next one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke on the scripted side
&lt;/h2&gt;

&lt;p&gt;Scripts break as well. The difference is how they break.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cost number was wrong.&lt;/strong&gt; On ticket-tracker, one ticket reached $402 over nine agent runs and nothing in the app said so. The orchestrator already wrote the cost of every run to a local log, but nothing showed it where the work was. Putting the number on the ticket had a trap: Claude Code's &lt;code&gt;total_cost_usd&lt;/code&gt; is cumulative across &lt;code&gt;--resume&lt;/code&gt;. Run 8 made one API call with zero output tokens and reported $392.83, the same as run 7. The fix is a few lines in the orchestrator. It keeps the last total per session and posts the difference. The full fix is in &lt;a href="https://panth.vardayinitech.in/blog/cost-per-turn-headless-claude-code" rel="noopener noreferrer"&gt;$402 on one ticket&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Polling was most of the bill.&lt;/strong&gt; Two orchestrator loops asked the tracker's API for the full ticket and event lists every 30 seconds or so. Over three days that was 8,822 + 8,815 calls, 88% of every request the API served. The fix was again in the script. Agents now hold one stream on a small revision node and fetch only the delta when it moves. That one is in &lt;a href="https://panth.vardayinitech.in/blog/wake-on-a-revision-not-polling" rel="noopener noreferrer"&gt;Polling was 88% of my API calls&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The heartbeat was on the bill twice.&lt;/strong&gt; The first version kept each beat stream as a Firestore document: 1,440 writes per agent per day, plus a sweep that ran every minute to notice silence, 4,261 runs in three days. Beats moved to the Realtime Database, about 100 bytes each, and silence is now worked out at read time from the age of the last beat.&lt;/p&gt;

&lt;p&gt;None of these needed an agent to diagnose. Each showed up in a log or a bill, and each was fixed in one place in the code. That is the trade I wanted from the split. The agent's mistakes stay inside a ticket's stack, where a bad change hit a copy. The scripts' mistakes show up in a log, the same way twice, so they can be fixed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the boring 90% makes the 10% useful
&lt;/h2&gt;

&lt;p&gt;Most of what makes the pipeline worth using comes from the scripted part. Per-ticket isolation is what lets the agent work freely, and lets a ticket sit in QAQC for a week while three others ship. The thread is the audit trail because scripts post into it at every step: the preview, the cost of each turn, the PR and commit links, and the rollback if there is one. Stored prompts are reviewable because the thread is the only channel. Human QA and dev-only scope are the two places we chose not to automate, and both are easier to hold when everything around them is predictable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The order I'd build it in
&lt;/h2&gt;

&lt;p&gt;If you're setting up something like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Isolation first. If every task can't run in its own branch, with its own services and its own data, nothing after it is safe.&lt;/li&gt;
&lt;li&gt;A handoff surface the team already uses. For us that was the ticket.&lt;/li&gt;
&lt;li&gt;Script every step that is the same on every ticket: provisioning, git, CI, deploy, health, rollback, and the receipts.&lt;/li&gt;
&lt;li&gt;Only then, the agent inside the box, with the smallest job you can give it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://panth.vardayinitech.in/blog/ticket-is-the-interface" rel="noopener noreferrer"&gt;The ticket is the interface&lt;/a&gt; has the walkthrough video and the first write-up of the pipeline.&lt;/p&gt;

&lt;p&gt;If your team runs coding agents, which step is the agent doing today that a script would do the same way every time?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I lead the software team at &lt;a href="https://oizom.com" rel="noopener noreferrer"&gt;Oizom&lt;/a&gt;, where Envizom turns data from air-quality and environmental monitors in 80+ countries into something cities and industries can act on. I dropped out before the AI wave, because I figured the engineering was in solving problems in the real world, and my college made me choose between attendance and real-world applications. Find me on &lt;a href="https://www.linkedin.com/in/panth-patel-447a88240/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>automation</category>
      <category>cicd</category>
    </item>
    <item>
      <title>On-Prem Manager: one engineer per two on-prem Docker sites didn't scale</title>
      <dc:creator>Panth Patel</dc:creator>
      <pubDate>Fri, 23 Oct 2026 13:01:04 +0000</pubDate>
      <link>https://dev.to/panthpatel/on-prem-manager-one-engineer-per-two-on-prem-docker-sites-didnt-scale-4fo6</link>
      <guid>https://dev.to/panthpatel/on-prem-manager-one-engineer-per-two-on-prem-docker-sites-didnt-scale-4fo6</guid>
      <description>&lt;p&gt;About five on-premise deployments a year, and one dedicated engineer for every two of them. That was the shape of our setup when we realised on-premise was going to be most of our business.&lt;/p&gt;

&lt;p&gt;I'm Panth, and I lead the software team at Oizom, where we build Envizom, an air quality monitoring platform. This post is about the deployment problem that came with selling it, and &lt;a href="https://github.com/Panth977/on-premise-manager" rel="noopener noreferrer"&gt;On-Prem Manager&lt;/a&gt; (OPM), the tool I built and open-sourced under Apache-2.0 to fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why on-premise
&lt;/h2&gt;

&lt;p&gt;Many organisations don't want to hand their data to a cloud. Governments most of all, and air quality is first a government responsibility. So when a government picks software like Envizom, it also needs that software on its own servers, for its own security reasons.&lt;/p&gt;

&lt;p&gt;The more we sold, the clearer it got: local deployment is the one real selling point for an air quality platform, and per-client customisation is what wins the client.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a deployment actually looks like
&lt;/h2&gt;

&lt;p&gt;The ideal case: the client gives you exactly what you asked for, and if something breaks on their side, a firewall rule or an IP config, they own it.&lt;/p&gt;

&lt;p&gt;That is not what happens. One client wants a DC/DR setup. Another wants high availability. Another wants a single server. Another wants DC/DR eventually, but for now on one server, with a second one later and two more after that.&lt;/p&gt;

&lt;p&gt;Then there are the integrations: pull data from one of their systems, or expose an API in a schema they hand us for a public display. None of that belongs in our production code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we had
&lt;/h2&gt;

&lt;p&gt;An end-to-end setup on Docker Compose, with Portainer as the GUI.&lt;/p&gt;

&lt;p&gt;After enough deployments it was clear Compose wasn't enough. Every client is different, so every install meant a lot of configuration and setup afterwards. A simple issue took hours to close, because debugging a remote server through that stack is slow.&lt;/p&gt;

&lt;p&gt;That setup is fine at five deployments a year with one engineer for every two. It is the bottleneck the moment you want more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the idea came from
&lt;/h2&gt;

&lt;p&gt;Oizom started as an IoT company. It ships devices with software inside, and every device sits at a remote location where nobody can do anything by hand. For that, Oizom uses a device manager, Aikaan.&lt;/p&gt;

&lt;p&gt;So: what if our deployment team had the same thing for on-premise servers?&lt;/p&gt;

&lt;p&gt;What the team needs to run a deployment is a short list. Automated volume backups. A way to make a new version live. Environment and config per site. Variables, with separate secrets for each deployment. A GUI to see the containers. I wrote down every problem we hit in operations and built a tool around the list.&lt;/p&gt;

&lt;h2&gt;
  
  
  What OPM is
&lt;/h2&gt;

&lt;p&gt;Two parts.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Part&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cloud console&lt;/td&gt;
&lt;td&gt;The catalog of images, deployment templates, variables, secrets and files. You commit a change once and review the diff per site. Self-host it on a VM (SQLite and local files) or run it on Firebase, same frontend build.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent&lt;/td&gt;
&lt;td&gt;One Go binary per server, with its own GUI built in. It applies commits to that server's Docker, runs an embedded Caddy as the edge proxy, snapshots volumes, and rolls back.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The README puts it as: like Portainer, plus a git-style commit history for every server's config.&lt;/p&gt;

&lt;h3&gt;
  
  
  Install is one line
&lt;/h3&gt;

&lt;p&gt;The cloud's Setup page gives a command per server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer opi_…"&lt;/span&gt; &amp;lt;apiBase&amp;gt;/v1/install/script | &lt;span class="nb"&gt;sudo &lt;/span&gt;sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs preflight checks, uses the Docker that is already there (20.10 or newer) or asks before installing a pinned version, downloads the agent and checks its sha256 and Ed25519 signature, writes the config, sets up a systemd service, and waits up to 60 seconds for a health check, rolling itself back if that fails. Everything it installs is pinned when the script is generated.&lt;/p&gt;

&lt;h3&gt;
  
  
  Images without a registry
&lt;/h3&gt;

&lt;p&gt;You register images in the cloud's catalog, and the cloud keeps its own copy of their layers. The agent loads images from those blobs with &lt;code&gt;docker load&lt;/code&gt; and never connects to a Docker registry, so it never sees a registry password. Sites in the same deployment copy layers from each other first, and only one site with internet fetches from the cloud.&lt;/p&gt;

&lt;h3&gt;
  
  
  Commits, not proposals
&lt;/h3&gt;

&lt;p&gt;Templates, deployments and each site are branches of commits. The latest commit on a deployment's branch is what its sites should run. You can compare any two branches and cherry-pick between them.&lt;/p&gt;

&lt;p&gt;Nothing changes on a site until a person on that site merges it, in the agent GUI. A merge runs one fixed sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LOCK -&amp;gt; SNAPSHOT -&amp;gt; APPLY -&amp;gt; VERIFY -&amp;gt; COMMIT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;VERIFY waits for each container to pass its health check, or to run 10 seconds without a restart, within 300 seconds by default. Any failure rolls back, and a failed merge leaves no commit. The failed container is kept for 24 hours under a &lt;code&gt;-failed-&amp;lt;time&amp;gt;&lt;/code&gt; name, with its last 200 log lines, so the post-mortem doesn't depend on someone grabbing logs in time.&lt;/p&gt;

&lt;p&gt;The dangerous act isn't the merge, it's restoring data. So rollbacks and volume restores get a 60-second countdown and a typed &lt;code&gt;ACCEPT&lt;/code&gt;. The spec's reasoning: "the commit message + plan review are the deliberate act… restoring data is still the dangerous act".&lt;/p&gt;

&lt;h3&gt;
  
  
  Backups by default
&lt;/h3&gt;

&lt;p&gt;Before every update and rollback, the agent snapshots config, image references and the volumes marked for backup, as &lt;code&gt;.tar.zst&lt;/code&gt; archives. It keeps the last 10. A scheduled snapshot runs daily at 02:00 site time and keeps the last 7. A snapshot refuses to start if free disk is under 1.2 times its estimate. Snapshots download as signed &lt;code&gt;.opmsnap&lt;/code&gt; files.&lt;/p&gt;

&lt;h3&gt;
  
  
  Docker stays the source of truth
&lt;/h3&gt;

&lt;p&gt;People still change things in Docker directly. The agent scans every 60 seconds and on Docker events. A hand edit to an image, env, port, mount or file shows up as uncommitted drift, and merges are refused until it is committed or discarded. A site can also auto-commit drift once it has been stable for 30 seconds, authored as "changed in Docker".&lt;/p&gt;

&lt;h3&gt;
  
  
  Standalone first
&lt;/h3&gt;

&lt;p&gt;Every agent GUI can do everything locally with no cloud at all: create containers, change images, edit files, variables and secrets. The cloud is how you run many sites, not something a site needs to keep running.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the client cuts the internet
&lt;/h2&gt;

&lt;p&gt;Some clients take the server off the internet for security. OPM has three routes to the cloud, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The server's own internet.&lt;/li&gt;
&lt;li&gt;A peer site in the same deployment that has internet, relaying.&lt;/li&gt;
&lt;li&gt;The browser of the person using the agent GUI. The GUI tab long-polls the agent and runs the cloud requests from the browser, so an engineer's laptop on the client network becomes the tunnel.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The agent only ever talks HTTPS on 443 to one API domain, which is one line in a client's firewall request.&lt;/p&gt;

&lt;p&gt;And for a client that allows neither the agent nor the cloud, the cloud can mirror every branch to a GitHub repository. A machine checks out its site's branch, fills in &lt;code&gt;site.env&lt;/code&gt; and runs &lt;code&gt;./deploy.sh&lt;/code&gt;: plain &lt;code&gt;docker compose&lt;/code&gt;, Caddy as the router, Portainer as the GUI, no OPM code on the box. A real-Docker test compares that output with the agent's and reports 0 differences. You lose drift detection, snapshots and rollback, which is why it's the last resort.&lt;/p&gt;

&lt;h2&gt;
  
  
  The size of it
&lt;/h2&gt;

&lt;p&gt;The full specification, &lt;code&gt;SPEC-final.md&lt;/code&gt;, is 894 KB, and its decision log has 116 numbered entries, each with the reason. The code is about 97,000 lines of Go, 96,000 of TypeScript and 60,000 of Svelte, with 105 Go test files and 157 TypeScript test files. There is an end-to-end harness that runs real agents against real Docker.&lt;/p&gt;

&lt;p&gt;Integrations got their own tool too. They run in &lt;a href="https://github.com/Panth977/code-shell" rel="noopener noreferrer"&gt;codeshell&lt;/a&gt;, a small browser editor in a container whose code is one volume. OPM's "Persist to cloud" versions a volume's content in the cloud, the docs name "client integration code" as the use, so that code is reviewed and kept there instead of in a git repository. codeshell gets its own post.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd ask you
&lt;/h2&gt;

&lt;p&gt;If you ship software that customers run on their own servers: what does one deployment cost you in engineer-hours, and which part of it did you automate first?&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>go</category>
      <category>opensource</category>
    </item>
    <item>
      <title>90% of our AI coding agent pipeline is plain scripted code</title>
      <dc:creator>Panth Patel</dc:creator>
      <pubDate>Thu, 22 Oct 2026 20:06:35 +0000</pubDate>
      <link>https://dev.to/panthpatel/90-of-our-ai-coding-agent-pipeline-is-plain-scripted-code-2mn4</link>
      <guid>https://dev.to/panthpatel/90-of-our-ai-coding-agent-pipeline-is-plain-scripted-code-2mn4</guid>
      <description>&lt;p&gt;Roughly 90% of the pipeline that ships our code is plain scripted code. The AI coding agent does one thing: the engineering work inside a single ticket's workspace, in response to what a person wrote in that ticket.&lt;/p&gt;

&lt;p&gt;We didn't start there. The first version gave the agent more of the operational steps, and this post is about why we took them back.&lt;/p&gt;

&lt;p&gt;I lead the software team at Oizom, where Envizom is our environmental monitoring platform. The pipeline below is our dev pipeline for Envizom.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of it
&lt;/h2&gt;

&lt;p&gt;A developer writes what they want in a ticket, in plain English, and drags it to &lt;strong&gt;Approved&lt;/strong&gt;. Our orchestrator picks it up and gives the ticket its own stack: a branch in every repo named after the ticket, its own backend, drivers, simulator and frontend, a copy of the dev database, and a private preview URL with an HTTPS certificate issued on demand.&lt;/p&gt;

&lt;p&gt;The agent works inside that stack and talks to the developer only through the ticket thread. When it's done it posts the preview link and moves the ticket to &lt;strong&gt;QAQC&lt;/strong&gt;. The developer tests the preview, replies in the thread, and either sends it back or drags it on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Approved ──► [scripts] provision stack ──► [agent] engineering work ──► QAQC
                                                    ▲                    │
                                                    └── feedback ────────┤
                                                                         ▼
Completed ◄── [scripts] commit, merge, PR, CI, deploy, health ◄── Git Push
     │                         │
     │                         └── unhealthy: roll back images, revert merges
     ▼
 stack torn down
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One box in that diagram has the agent in it. Everything else is a script.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the scripts do
&lt;/h2&gt;

&lt;p&gt;Here is the list of jobs no agent touches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;watching the board&lt;/li&gt;
&lt;li&gt;provisioning the per-ticket stack&lt;/li&gt;
&lt;li&gt;copying the database&lt;/li&gt;
&lt;li&gt;issuing certificates&lt;/li&gt;
&lt;li&gt;driving git&lt;/li&gt;
&lt;li&gt;opening pull requests&lt;/li&gt;
&lt;li&gt;waiting on CI&lt;/li&gt;
&lt;li&gt;deploying&lt;/li&gt;
&lt;li&gt;health checks&lt;/li&gt;
&lt;li&gt;rollback&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The clearest example is what happens when a developer drags a ticket to &lt;strong&gt;Git Push&lt;/strong&gt;. From that moment there is no agent involved. The orchestrator runs eight steps:&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;Step&lt;/th&gt;
&lt;th&gt;Done by&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Commit the work on the ticket branches&lt;/td&gt;
&lt;td&gt;script&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Merge the current dev branch into them&lt;/td&gt;
&lt;td&gt;script&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Open a pull request in each affected repo&lt;/td&gt;
&lt;td&gt;script&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Merge the pull requests&lt;/td&gt;
&lt;td&gt;script&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Post the PR and commit links back into the ticket thread&lt;/td&gt;
&lt;td&gt;script&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Wait for CI&lt;/td&gt;
&lt;td&gt;script&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Roll the new images onto the dev VM&lt;/td&gt;
&lt;td&gt;script&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Run health checks against the VM&lt;/td&gt;
&lt;td&gt;script&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If the VM isn't healthy afterwards, the orchestrator rolls it back to the previous images and reverts the merges, on its own, and then tells the thread what happened. If it is healthy, the ticket moves to &lt;strong&gt;Completed&lt;/strong&gt; and the developer gets a notification. The ticket's stack is torn down after that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we took the steps back from the agent
&lt;/h2&gt;

&lt;p&gt;We tried it the other way first, with the agent doing more of the operational steps. It was slower, it was more expensive, and it was worse at exactly the tasks that need to be boring.&lt;/p&gt;

&lt;p&gt;That last one is the reason that matters. Merging, deploying and rolling back should happen the same way on every ticket, and that sameness is the whole value. An agent deciding how to merge is not a feature. A script that always merges the same way is.&lt;/p&gt;

&lt;p&gt;The scripted part is deterministic and tested. When it breaks, I read a log and fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the agent earns its place
&lt;/h2&gt;

&lt;p&gt;The agent's job is the engineering work itself. In the walkthrough video I asked for a camera layout change on a device page. The agent shipped it and posted the preview. I replied in the thread asking it to also handle more than 4 cameras with a horizontal scroll, and to set up 8 test cameras on that device. It did both, including creating the test data in the ticket's own database copy, and it picked up with the same context it had before.&lt;/p&gt;

&lt;p&gt;Isolation is what lets it do that safely. Because every ticket has its own stack, an agent that does something silly to the database did it to a copy. A developer can have several tickets in flight at once, and one ticket can sit in QAQC for a week while three others ship.&lt;/p&gt;

&lt;p&gt;Two more lines we drew on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;QA is human.&lt;/strong&gt; The developer tests the preview and decides whether the ticket moves forward. An agent grading its own work would remove the one review that matters most.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This pipeline stops at dev.&lt;/strong&gt; Auto-merge, rollback and VM updates all apply to the dev environment only. Production goes through a separate path where people make the call, on a slower cadence.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The order I'd build it in
&lt;/h2&gt;

&lt;p&gt;If you're setting up something similar, this is the order we ended up with:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Isolation first. If every task can't run in its own branch, its own services and its own data, nothing after it is safe.&lt;/li&gt;
&lt;li&gt;A handoff surface the team already uses. For us that was the ticket.&lt;/li&gt;
&lt;li&gt;Only then, the agent inside the box.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And keep the box scripted. The agent should be the smallest part of the system.&lt;/p&gt;

&lt;p&gt;The longer write-up, with the video walkthrough, is &lt;a href="https://panth.vardayinitech.in/blog/ticket-is-the-interface" rel="noopener noreferrer"&gt;The ticket is the interface&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If your team runs coding agents, which step did you hand to the agent that a script should own?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>automation</category>
      <category>cicd</category>
    </item>
    <item>
      <title>0 reads to switch views: Firestore board filters in the browser</title>
      <dc:creator>Panth Patel</dc:creator>
      <pubDate>Thu, 22 Oct 2026 19:57:41 +0000</pubDate>
      <link>https://dev.to/panthpatel/0-reads-to-switch-views-firestore-board-filters-in-the-browser-187a</link>
      <guid>https://dev.to/panthpatel/0-reads-to-switch-views-firestore-board-filters-in-the-browser-187a</guid>
      <description>&lt;p&gt;Switching between saved views on a board in my tracker costs zero Firestore reads. The board's active tickets are loaded once and kept live, and every filter, sort, group-by and swimlane is a pure function run over them in the browser.&lt;/p&gt;

&lt;p&gt;The constraint that forced it: Firestore cannot OR across fields, and every combination of filters you want to query needs its own composite index. A tracker with user-defined views over custom fields would need thousands of them.&lt;/p&gt;

&lt;p&gt;I'm Panth, and I lead the software team at Oizom. This is from &lt;a href="https://github.com/Panth977/ticket-tracker" rel="noopener noreferrer"&gt;ticket-tracker&lt;/a&gt;, my open-source board. Its decisions page lists the choices that are expensive to reverse, each with the alternative that was turned down. This is D5.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision
&lt;/h2&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;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Chosen&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One live query per board over its active tickets. Filter, sort, group-by and swimlanes are pure functions in &lt;code&gt;packages/shared&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Rejected&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One Firestore query per view.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Why&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Firestore cannot OR across fields, cannot filter on range and inequality over two fields, and needs a composite index for every combination. User-defined views with custom fields would need thousands of indexes. In the browser, switching views is instant and costs no reads.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ceiling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;About 5,000 active tickets per board, or about 5 MB.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A saved view is a few lines of data. A filter is a tree of &lt;code&gt;and&lt;/code&gt; / &lt;code&gt;or&lt;/code&gt; groups over leaves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;FilterGroup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;and&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;or&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FilterNode&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;FilterLeaf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;cmp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Cmp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;value&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;FilterNode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;FilterGroup&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;FilterLeaf&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A leaf can compare any built-in field (stage, priority, assignee, tag, due date, state, creator, text, linked) or any custom field, with twelve comparisons from &lt;code&gt;is&lt;/code&gt; to &lt;code&gt;between&lt;/code&gt;. "Mine and high priority, or anything of Ann's" is this, straight from the tests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FilterNode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;or&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;and&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assignee&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cmp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;is&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;me&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;priority&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cmp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;is&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hi&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assignee&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cmp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;is&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ann&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As a Firestore query, that OR spans two fields, and the tree can nest as deep as people like. As a function over an array, it's a recursive &lt;code&gt;evalNode&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  One function draws every view
&lt;/h2&gt;

&lt;p&gt;The whole engine is one call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;applyView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tickets&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;board&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="nx"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It does four things, in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Keep the ticket states the view asks for. The default is active only, so archived tickets leave every default view.&lt;/li&gt;
&lt;li&gt;Run the filter tree.&lt;/li&gt;
&lt;li&gt;Sort on several keys, ties broken by rank and then id, so every client gets the same order.&lt;/li&gt;
&lt;li&gt;Group, then group again inside each group for swimlanes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Kanban, table, calendar and timeline all draw from what it returns. A ticket with two assignees or three tags appears in each of their groups. Swimlanes are computed over all visible tickets, so every column has the same rows.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;me&lt;/code&gt;, &lt;code&gt;today&lt;/code&gt;, &lt;code&gt;thisWeek&lt;/code&gt; and &lt;code&gt;overdue&lt;/code&gt; are tokens in the saved view, resolved when the function runs, against the viewer's id and time zone. A shared view called "Mine, due this week" means the same thing to everyone who opens it, each in their own week.&lt;/p&gt;

&lt;p&gt;Because the function is pure, it's tested as one: the test file is 453 lines against 665 lines of engine, with a fixed clock (Tuesday 2026-09-22 in Kolkata) and a board with six custom field types.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one query, and what it costs
&lt;/h2&gt;

&lt;p&gt;The query that feeds the engine is about as simple as Firestore gets: the board's tickets where &lt;code&gt;state == 'active'&lt;/code&gt;, one listener per board. It needs no composite index.&lt;/p&gt;

&lt;p&gt;Loading the whole board on every open would pay one read per card. So the board store is incremental. The first open runs the full query. After that, the device starts from Firestore's own IndexedDB cache of the last snapshot and asks only for tickets whose &lt;code&gt;updatedAt&lt;/code&gt; is newer than the newest one it has seen. An unchanged board answers that with no documents, which Firestore bills as one read, whatever the board's size.&lt;/p&gt;

&lt;p&gt;The delta query has no &lt;code&gt;state&lt;/code&gt; filter, on purpose. A ticket that leaves the active set has to arrive so it can be dropped from the board, and a query for active tickets would never send it. The browser decides what to keep. One inequality on one field also means the delta needs no composite index.&lt;/p&gt;

&lt;p&gt;A hard delete doesn't show up in any &lt;code&gt;updatedAt &amp;gt;&lt;/code&gt; query, because the document is gone. So the watermark is trusted for 6 hours from when it was written. After that, the next open pays for one full query and agrees with the server again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stops
&lt;/h2&gt;

&lt;p&gt;The decision writes down its own ceiling: about 5,000 active tickets, or about 5 MB, per board. Past that, the plan is to flag the board as large and run its views through Typesense, which can filter on every field, with paging. Archived tickets are never loaded by default, so the number that matters is open work, not history.&lt;/p&gt;

&lt;p&gt;For a personal tool, where a board is the unit people share, 5,000 open tickets is far more than a board should hold. The trade is the right one for this product. It wouldn't be for a tracker that hosts a whole company on one board.&lt;/p&gt;

&lt;p&gt;What I'd take from this for any Firestore app with user-built filters: if the data a screen needs fits on the device, query it once, keep it live, and do the rest in code you can unit test. Indexes are for the data that doesn't fit.&lt;/p&gt;

&lt;p&gt;Where do your filters run: in the database, or over data you've already loaded?&lt;/p&gt;

</description>
      <category>firestore</category>
      <category>sveltekit</category>
      <category>typescript</category>
      <category>kanban</category>
    </item>
    <item>
      <title>MCP on OAuth 2.1: one URL and a consent click, no API key</title>
      <dc:creator>Panth Patel</dc:creator>
      <pubDate>Tue, 20 Oct 2026 23:52:57 +0000</pubDate>
      <link>https://dev.to/panthpatel/mcp-on-oauth-21-one-url-and-a-consent-click-no-api-key-3af8</link>
      <guid>https://dev.to/panthpatel/mcp-on-oauth-21-one-url-and-a-consent-click-no-api-key-3af8</guid>
      <description>&lt;p&gt;Adding my tracker to Claude takes one URL and one click on a consent screen. Nobody makes a key, copies it, or pastes it anywhere.&lt;/p&gt;

&lt;p&gt;The constraint: an MCP client that a person uses every day should not hold a long-lived secret the person had to handle by hand, and the person should be able to see and cut off what it can reach.&lt;/p&gt;

&lt;p&gt;I'm Panth, and I lead the software team at Oizom. This is from &lt;a href="https://github.com/Panth977/ticket-tracker" rel="noopener noreferrer"&gt;ticket-tracker&lt;/a&gt;, my open-source board. Its decisions page lists the choices that are expensive to reverse, each with the alternative that was turned down. This is D9.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision
&lt;/h2&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;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Chosen&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;OAuth 2.1 with PKCE, dynamic client registration, and protected-resource metadata, as the MCP authorization spec expects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Rejected&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Pasting an API key into the MCP client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Why&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;It is the only way a person adds the server with one URL and a consent click, in Claude and other clients. The grant is per person, can be revoked in Account › Connected apps, and every change it makes is attributed &lt;code&gt;via MCP&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;API keys did not go away. They still work on &lt;code&gt;/mcp&lt;/code&gt; for headless setups, where nobody is there to click Allow. Claude Code takes a token in a header, and so do the orchestrators that run my agents. OAuth is for the case where a person is sitting in front of the client.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the client does, with nobody pasting anything
&lt;/h2&gt;

&lt;p&gt;The whole flow is discovery. The client starts knowing one URL, and every other address comes from a document the server publishes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. POST /mcp, no token
   ← 401  WWW-Authenticate: Bearer resource_metadata=".../.well-known/oauth-protected-resource/mcp"
2. GET  /.well-known/oauth-protected-resource/mcp        (RFC 9728)
   ← { authorization_servers: [base], scopes_supported: [...] }
3. GET  /.well-known/oauth-authorization-server          (RFC 8414)
   ← authorize, token, register and revoke endpoints; PKCE S256 only
4. POST /oauth/register                                  (RFC 7591)
   ← client_id
5. GET  /oauth/authorize?...&amp;amp;code_challenge=...  → our consent screen
6. person picks boards, presses Allow  → redirect with ?code=
7. POST /oauth/token  code + code_verifier  ← access + refresh tokens
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 1 carries the design. The 401 is not "your token is bad". It is "go and sign in, and here is where to start". A client that has never heard of this server can follow the header all the way to a working token.&lt;/p&gt;

&lt;p&gt;Some clients probe OIDC discovery first, so &lt;code&gt;/.well-known/openid-configuration&lt;/code&gt; answers with the same document.&lt;/p&gt;

&lt;h2&gt;
  
  
  The consent screen is where scope gets decided
&lt;/h2&gt;

&lt;p&gt;The screen names the app asking, lists the permissions it wants in plain words ("Read tickets", "Post, edit, pin and react in threads"), and asks which boards it may act on: every board, or the ones you tick. Nothing is granted until you press Allow.&lt;/p&gt;

&lt;p&gt;The server checks the ticked boards against the boards the person can actually read. A board you are not on is an error, not a silent drop.&lt;/p&gt;

&lt;p&gt;What the grant gives back shapes the MCP server itself. A tool the grant's scopes do not allow is not registered at all, so it is missing from &lt;code&gt;tools/list&lt;/code&gt;, and every handler checks again anyway. The model never sees a tool it would only get a 403 from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Token lifetimes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Token&lt;/th&gt;
&lt;th&gt;Lives&lt;/th&gt;
&lt;th&gt;Rules&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Authorization code&lt;/td&gt;
&lt;td&gt;60 s&lt;/td&gt;
&lt;td&gt;Single use, bound to the client, the redirect URI and the PKCE S256 challenge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Access token&lt;/td&gt;
&lt;td&gt;1 h&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refresh token&lt;/td&gt;
&lt;td&gt;30 days&lt;/td&gt;
&lt;td&gt;Rotated on every use&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Tokens are stored only as their SHA-256. The grant is a document under the person (&lt;code&gt;users/{uid}/oauthGrants/{grantId}&lt;/code&gt;), and it is the revocation switch: revoking deletes it and every token issued under it. Revoking in Connected apps stops the app on its next call, including sessions already open.&lt;/p&gt;

&lt;p&gt;A refresh may narrow scopes, never widen them. Asking for a scope the grant does not hold fails with &lt;code&gt;invalid_scope&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that catches a stolen refresh token
&lt;/h2&gt;

&lt;p&gt;Rotation alone does not help if nobody notices the old token coming back. So a used refresh token is kept, marked with when it was used, until it expires. If it is presented again, that is two parties holding the same token: the real client and someone else. The server revokes the whole grant.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A client that refreshes twice at once, or retries after losing the response,&lt;/span&gt;
&lt;span class="c1"&gt;// presents the same token seconds apart: that is not theft.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;retry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usedAt&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usedAt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;REFRESH_RETRY_GRACE_MS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usedAt&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// THEFT DETECTION: an already-rotated refresh token came back.&lt;/span&gt;
  &lt;span class="nx"&gt;reused&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;grantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;grantId&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The grace window is 60 seconds. Without it, a client that fires two refreshes in parallel, or loses the response and retries, would lock its own user out. With it, a retry gets a fresh pair and a replay an hour later kills the grant with "Refresh token reuse detected — the app must be connected again".&lt;/p&gt;

&lt;h2&gt;
  
  
  The small checks that keep it from becoming an open door
&lt;/h2&gt;

&lt;p&gt;Dynamic registration means anyone can register a client, so the edges matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Registration is rate-limited&lt;/strong&gt; per IP address: 20 a minute, 500 a day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redirect URIs&lt;/strong&gt; must be &lt;code&gt;https&lt;/code&gt;, or &lt;code&gt;http&lt;/code&gt; on &lt;code&gt;localhost&lt;/code&gt;, &lt;code&gt;127.0.0.1&lt;/code&gt; or &lt;code&gt;[::1]&lt;/code&gt; only, or a private-use scheme such as &lt;code&gt;cursor://&lt;/code&gt; or &lt;code&gt;vscode://&lt;/code&gt;. &lt;code&gt;javascript:&lt;/code&gt;, &lt;code&gt;data:&lt;/code&gt;, &lt;code&gt;file:&lt;/code&gt;, &lt;code&gt;vbscript:&lt;/code&gt; and &lt;code&gt;blob:&lt;/code&gt; are refused, and so is any URI with a fragment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A bad client or redirect URI is shown as an error page, never redirected to.&lt;/strong&gt; Redirecting there would make the server an open redirector. Any other error goes back to the client as &lt;code&gt;?error=&lt;/code&gt; the way RFC 6749 says.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only two grant types&lt;/strong&gt;: &lt;code&gt;authorization_code&lt;/code&gt; and &lt;code&gt;refresh_token&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;One more surface to own. An API key is one table and one header check. This is an authorization server with eight endpoints, a consent screen in the app, and a Connected apps page. My own review notes had a row for it: the MCP flow pointed at a consent page that did not exist yet. That was fixed by building the page at &lt;code&gt;/oauth/consent&lt;/code&gt;, after which the grant shows in Connected apps.&lt;/p&gt;

&lt;p&gt;For an MCP server people add to Claude by hand, I would pay it again. The person never touches a secret, sees exactly which boards they handed over, and has one Revoke button.&lt;/p&gt;

&lt;p&gt;If you run an MCP server, which do your users hold today: an OAuth grant or a key they pasted?&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>oauth</category>
      <category>security</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
