<?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: Rohan Sen Sharma</title>
    <description>The latest articles on DEV Community by Rohan Sen Sharma (@rss_holmes).</description>
    <link>https://dev.to/rss_holmes</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%2F371241%2Fab94efec-b2b8-4a26-a1fe-5f7df7180093.jpeg</url>
      <title>DEV Community: Rohan Sen Sharma</title>
      <link>https://dev.to/rss_holmes</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rss_holmes"/>
    <language>en</language>
    <item>
      <title>I Tried 28 Claude Code Skills. Here Are the 5 I Kept</title>
      <dc:creator>Rohan Sen Sharma</dc:creator>
      <pubDate>Thu, 15 Oct 2026 03:00:00 +0000</pubDate>
      <link>https://dev.to/rss_holmes/i-tried-28-claude-code-skills-here-are-the-5-i-kept-1b9a</link>
      <guid>https://dev.to/rss_holmes/i-tried-28-claude-code-skills-here-are-the-5-i-kept-1b9a</guid>
      <description>&lt;p&gt;&lt;strong&gt;The best Claude Code skills, for me, are ponytail, caveman, graphify, grill-me and ui-ux-pro-max: five out of 28 I tried.&lt;/strong&gt; Each one does a narrow job the model does not already do well. The other 23 went, mostly because the models have absorbed what they used to add, and every installed skill costs context.&lt;/p&gt;

&lt;p&gt;That is the thesis of this post. Six months ago, the big skill frameworks were the fastest way to make a coding agent plan, test and review properly. Today a lot of that behaviour is built into the models. So I now keep the smallest set that still changes the result, and I judge every skill by one question: does it earn the tokens it loads?&lt;/p&gt;

&lt;h2&gt;
  
  
  The 5 best Claude Code skills at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Skill&lt;/th&gt;
&lt;th&gt;What it is for&lt;/th&gt;
&lt;th&gt;Stars (5 Oct 2026)&lt;/th&gt;
&lt;th&gt;Works in Codex&lt;/th&gt;
&lt;th&gt;Install in Claude Code&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ponytail&lt;/td&gt;
&lt;td&gt;Making the agent write the least code that works&lt;/td&gt;
&lt;td&gt;155k&lt;/td&gt;
&lt;td&gt;Yes, as a Codex plugin&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/plugin marketplace add DietrichGebert/ponytail&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;caveman&lt;/td&gt;
&lt;td&gt;Terser agent output; optional proxy that compresses what it reads&lt;/td&gt;
&lt;td&gt;110k&lt;/td&gt;
&lt;td&gt;Yes, via &lt;code&gt;npx skills add&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;claude plugin install caveman@caveman&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;graphify&lt;/td&gt;
&lt;td&gt;A queryable knowledge graph of your code and docs&lt;/td&gt;
&lt;td&gt;124k&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pip install graphifyy &amp;amp;&amp;amp; graphify install&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;grill-me&lt;/td&gt;
&lt;td&gt;A relentless interview to sharpen a plan&lt;/td&gt;
&lt;td&gt;276k (repo)&lt;/td&gt;
&lt;td&gt;Yes, via &lt;code&gt;npx skills add&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/plugin install mattpocock-skills&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ui-ux-pro-max&lt;/td&gt;
&lt;td&gt;Design intelligence for building interfaces&lt;/td&gt;
&lt;td&gt;133k&lt;/td&gt;
&lt;td&gt;Yes, via its CLI&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/plugin marketplace add nextlevelbuilder/ui-ux-pro-max-skill&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Stars are context, not a ranking.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. ponytail: the laziest senior dev in the room
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/DietrichGebert/ponytail" rel="noopener noreferrer"&gt;ponytail&lt;/a&gt; (MIT) pushes the agent to write the least code that does the job. Since version 5 (8 October 2026), its README reports about 53% less code, 26% lower cost and 41% faster runs; those are the author's numbers, from their own benchmark, not mine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Install:&lt;/strong&gt; in Claude Code, &lt;code&gt;/plugin marketplace add DietrichGebert/ponytail&lt;/code&gt;, then &lt;code&gt;/plugin install ponytail@ponytail&lt;/code&gt;. In Codex, &lt;code&gt;codex plugin marketplace add DietrichGebert/ponytail&lt;/code&gt;, then &lt;code&gt;codex plugin add ponytail@ponytail&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; it works through hooks that run on their own: three in Claude Code (SessionStart, SubagentStart and UserPromptSubmit), and in Codex you have to trust its two lifecycle hooks before it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. caveman: why many token when few do trick
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/JuliusBrussee/caveman" rel="noopener noreferrer"&gt;caveman&lt;/a&gt; (Apache-2.0) makes the agent say less in controlled, terse English while keeping code exact. An optional local proxy also compresses what the agent reads, such as logs, JSON and test output; the README reports 33.2% fewer input tokens across 54 Claude Code runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Install:&lt;/strong&gt; &lt;code&gt;claude plugin marketplace add JuliusBrussee/caveman &amp;amp;&amp;amp; claude plugin install caveman@caveman&lt;/code&gt;, or &lt;code&gt;npx skills add JuliusBrussee/caveman -g&lt;/code&gt; for Codex and other agents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; two facts worth knowing. The proxy sits between your agent and the model API, on your machine, with your keys. And the separate &lt;code&gt;caveman&lt;/code&gt; CLI sends usage statistics by default: commands, token counts, a random install ID, OS and IP, never prompts or code. Turn that off with &lt;code&gt;caveman telemetry off&lt;/code&gt; or &lt;code&gt;DO_NOT_TRACK=1&lt;/code&gt;. The skill on its own sends nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. graphify: your codebase as a graph
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/Graphify-Labs/graphify" rel="noopener noreferrer"&gt;graphify&lt;/a&gt; (Apache-2.0) turns a codebase, its docs, SQL schemas and PDFs into a queryable knowledge graph, written out as &lt;code&gt;graph.html&lt;/code&gt;, &lt;code&gt;GRAPH_REPORT.md&lt;/code&gt; and &lt;code&gt;graph.json&lt;/code&gt;, and queried with &lt;code&gt;/graphify&lt;/code&gt;. Code is parsed locally with tree-sitter and does not leave the machine; docs and media go through your assistant's model or an API key you configure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Install:&lt;/strong&gt; &lt;code&gt;pip install graphifyy &amp;amp;&amp;amp; graphify install&lt;/code&gt; (two y's; &lt;code&gt;uv tool install graphifyy&lt;/code&gt; works too); &lt;code&gt;graphify install --project --platform codex&lt;/code&gt; sets it up for Codex.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; it is a busy project, with around 1,500 open issues, and the README points to a paid graphify.com platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. grill-me: a relentless interview for your plan
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/mattpocock/skills/tree/main/skills/productivity/grill-me" rel="noopener noreferrer"&gt;grill-me&lt;/a&gt;, part of Matt Pocock's skills collection (MIT), runs "a relentless interview to sharpen a plan or design". It hands off to a grilling skill that asks the hard questions in rounds, each with a recommended answer you can accept or override. It does not load itself: the skill sets &lt;code&gt;disable-model-invocation: true&lt;/code&gt;, so it only runs when you call it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Install:&lt;/strong&gt; &lt;code&gt;/plugin install mattpocock-skills&lt;/code&gt; (it is in Claude Code's official marketplace), or &lt;code&gt;npx skills@latest add mattpocock/skills&lt;/code&gt; for Codex and other agents.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. ui-ux-pro-max: design help that knows patterns
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/nextlevelbuilder/ui-ux-pro-max-skill" rel="noopener noreferrer"&gt;ui-ux-pro-max&lt;/a&gt; (MIT) adds design intelligence for building interfaces across platforms. Version 2.0 adds a design-system generator that proposes patterns, palettes and placement of calls to action for a project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Install:&lt;/strong&gt; &lt;code&gt;/plugin marketplace add nextlevelbuilder/ui-ux-pro-max-skill&lt;/code&gt;, then &lt;code&gt;/plugin install ui-ux-pro-max@ui-ux-pro-max-skill&lt;/code&gt;. For Codex, install its CLI with &lt;code&gt;npm install -g ui-ux-pro-max-cli&lt;/code&gt; and run &lt;code&gt;uipro init --ai codex&lt;/code&gt; in the project.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I removed (23 of 28)
&lt;/h2&gt;

&lt;p&gt;The two biggest names I removed were &lt;a href="https://github.com/obra/superpowers" rel="noopener noreferrer"&gt;Superpowers&lt;/a&gt; and &lt;a href="https://github.com/gsd-build/get-shit-done" rel="noopener noreferrer"&gt;Get Shit Done&lt;/a&gt;, now continued as &lt;a href="https://github.com/open-gsd/gsd-core" rel="noopener noreferrer"&gt;GSD Core&lt;/a&gt;. Both are good. Superpowers is a complete development methodology built from composable skills; GSD is a meta-prompting and spec-driven development system. Six months ago they made my agent plan, test and review in a way it would not on its own, and I used both.&lt;/p&gt;

&lt;p&gt;What changed is the models. A lot of what these frameworks did is now built in. Next to that, a large framework felt bloated for my use. I also try to keep context tokens for the task, which means loading the fewest skills that still change the outcome, and a full methodology loaded into every session is the opposite of that.&lt;/p&gt;

&lt;p&gt;Most of the rest fell into the same bucket: things the model now does by default, or things I could describe in two lines of project instructions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do these skills work in Codex?
&lt;/h2&gt;

&lt;p&gt;Mostly. Skills follow the open &lt;a href="https://agentskills.io" rel="noopener noreferrer"&gt;Agent Skills standard&lt;/a&gt;: a folder with a &lt;code&gt;SKILL.md&lt;/code&gt; file and optional scripts. Codex reads skills from &lt;code&gt;.agents/skills&lt;/code&gt; in a repository and from &lt;code&gt;~/.agents/skills&lt;/code&gt; for your user, and you call one with &lt;code&gt;$skill-name&lt;/code&gt;. Plugins are tool-specific, because they can bundle hooks and commands, so use each project's Codex install where it has one. All five above have a Codex route today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Are skills safe to install?
&lt;/h2&gt;

&lt;p&gt;A skill is instructions plus optional scripts that run with your permissions. Before you install one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read the SKILL.md.&lt;/strong&gt; It is short, and it tells you exactly what the skill asks the model to do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check for hooks.&lt;/strong&gt; Hooks run automatically on events, without a prompt. ponytail's Codex plugin asks you to trust two.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check for telemetry and network calls.&lt;/strong&gt; caveman's CLI is a good example of a tool that tells you plainly what it sends and how to stop it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer a clear licence and an active maintainer.&lt;/strong&gt; Every skill here is MIT or Apache-2.0 and was actively maintained when I checked in October 2026.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are deciding whether a capability belongs in a skill at all, or in an MCP server, &lt;a href="https://nulltensor.com/posts/claude-skills-might-be-bigger-than/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=claude-skills-might-be-bigger-than" rel="noopener noreferrer"&gt;Claude Skills vs MCP servers&lt;/a&gt; covers the difference. If you want to write your own, start with &lt;a href="https://nulltensor.com/posts/getting-started-with-claude-skills/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=getting-started-with-claude-skills" rel="noopener noreferrer"&gt;how to create and install a skill&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Best Claude Code skills: common questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What are the best Claude Code skills?&lt;/strong&gt; After trying 28, the five I kept are ponytail for writing less code, caveman for terser output, graphify for a knowledge graph of the codebase, grill-me for stress-testing a plan, and ui-ux-pro-max for interface design. The best set is a small one, because every installed skill competes for context.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do Claude Code skills work in Codex?&lt;/strong&gt; Mostly, yes. Skills follow the open Agent Skills standard, a folder with a SKILL.md file, and Codex reads them from .agents/skills in a repository or in your home directory. Plugins that bundle hooks or commands are tool-specific, so check each skill's README for a Codex install.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I install a skill in Claude Code?&lt;/strong&gt; Most published skills install as plugins: run /plugin marketplace add with the repository, then /plugin install. You can also copy a skill folder into .claude/skills in a project, or into ~/.claude/skills for every project. Writing your own takes one SKILL.md file with a name and a description.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Are Claude Code skills safe?&lt;/strong&gt; A skill is instructions plus optional scripts that run with your permissions, so treat it like any code you install. Read the SKILL.md, look for hooks that run automatically, check whether any bundled tool sends telemetry, and prefer skills with a clear licence and an active maintainer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the difference between Claude Code skills and MCP servers?&lt;/strong&gt; A skill teaches Claude a procedure, such as how to review code or plan a feature. An MCP server gives Claude access to another system, such as GitHub or a database. Skills change what Claude knows; MCP servers change what it can reach, and many workflows use both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fewer skills, better results
&lt;/h2&gt;

&lt;p&gt;The models will keep absorbing what today's skills add, so this list will shrink again. The habit that lasts is the question behind it: does this skill change the result enough to earn its place in every session?&lt;/p&gt;

&lt;p&gt;Which skill would you keep if you could only have one?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://nulltensor.com/posts/best-claude-code-skills/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=best-claude-code-skills" rel="noopener noreferrer"&gt;nulltensor.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claude</category>
      <category>ai</category>
      <category>productivity</category>
      <category>codex</category>
    </item>
    <item>
      <title>How to Build an MCP Server: Python, Remote and Ready for Production</title>
      <dc:creator>Rohan Sen Sharma</dc:creator>
      <pubDate>Wed, 14 Oct 2026 03:00:00 +0000</pubDate>
      <link>https://dev.to/rss_holmes/how-to-build-an-mcp-server-python-remote-and-ready-for-production-173g</link>
      <guid>https://dev.to/rss_holmes/how-to-build-an-mcp-server-python-remote-and-ready-for-production-173g</guid>
      <description>&lt;p&gt;&lt;strong&gt;To build an MCP server, wrap the capabilities you already have in a few well-described functions, using the official SDK, then serve them over HTTP with a token check in front.&lt;/strong&gt; The protocol work is done for you. What decides whether the server is any good is which tools you expose and how you describe them.&lt;/p&gt;

&lt;p&gt;This guide builds a small remote server in Python: one tool, the HTTP transport, authentication, a Cloud Run deploy and the monthly cost, then the lessons that only show up once a real agent uses it. The example is generic, the patterns are the ones we use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the decision, not the code
&lt;/h2&gt;

&lt;p&gt;An MCP server is worth building when several AI clients need the same capability, or a person's permissions must follow them into the agent. If one script calls your API on a schedule, just call the API, &lt;a href="https://nulltensor.com/posts/mcp-vs-api/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=mcp-vs-api" rel="noopener noreferrer"&gt;MCP vs API&lt;/a&gt; covers that decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  A minimal Python MCP server, with the official SDK
&lt;/h2&gt;

&lt;p&gt;Install the &lt;a href="https://github.com/modelcontextprotocol/python-sdk" rel="noopener noreferrer"&gt;official Python SDK&lt;/a&gt;. Version 2 calls the server class &lt;code&gt;MCPServer&lt;/code&gt;; on version 1 it is &lt;code&gt;FastMCP&lt;/code&gt;, in &lt;code&gt;mcp.server.fastmcp&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;uv add &lt;span class="s2"&gt;"mcp[cli]"&lt;/span&gt; httpx uvicorn
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is one tool over an existing orders API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.server&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;MCPServer&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.types&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ToolAnnotations&lt;/span&gt;

&lt;span class="n"&gt;API_BASE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;API_BASE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.example.com/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;mcp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MCPServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;annotations&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;ToolAnnotations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;read_only_hint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;order_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Return the status, items and expected delivery date of one order.

    Use this when someone asks where an order is or when it will arrive.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;API_BASE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/orders/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The type hints become the input schema the model fills in. The docstring is what the model reads to decide when to call the tool, so write it for someone who has never seen your system. The read-only annotation tells the client the tool changes nothing.&lt;/p&gt;

&lt;p&gt;Run it locally with &lt;code&gt;mcp.run()&lt;/code&gt; and connect it from Claude Code over stdio before you think about hosting. If you want to know how to add an MCP server to your client, be it Claude Code, Claude Desktop or claude.ai, &lt;a href="https://nulltensor.com/posts/what-is-claude-mcp/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=what-is-claude-mcp" rel="noopener noreferrer"&gt;What is Claude MCP&lt;/a&gt; walks through the commands and config files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building a remote MCP server: streamable HTTP
&lt;/h2&gt;

&lt;p&gt;A local server talks over standard input and output (stdio). A remote server answers HTTP on one path, so any client that can reach the URL can use it, including claude.ai in a browser. To put the same server on a port, name the transport in &lt;code&gt;run()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transport&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;streamable-http&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3001&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one line builds a Starlette app and serves it with uvicorn, and clients connect to &lt;code&gt;http://127.0.0.1:3001/mcp&lt;/code&gt;. Transport options such as &lt;code&gt;host&lt;/code&gt;, &lt;code&gt;port&lt;/code&gt; and &lt;code&gt;streamable_http_path&lt;/code&gt; go to &lt;code&gt;run()&lt;/code&gt;, not to &lt;code&gt;MCPServer(...)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;run()&lt;/code&gt; starts one process on localhost. To deploy, build the ASGI app yourself and allowlist the hostname you serve:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.server.transport_security&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TransportSecuritySettings&lt;/span&gt;

&lt;span class="n"&gt;security&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TransportSecuritySettings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;allowed_hosts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mcp.example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mcp.example.com:*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;allowed_origins&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://app.example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;streamable_http_app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transport_security&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;security&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then serve it with uvicorn:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uvicorn server:app &lt;span class="nt"&gt;--workers&lt;/span&gt; 4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;allowed_hosts&lt;/code&gt; is the go-live gate. By default the app only accepts requests addressed to localhost, a guard against DNS rebinding, so a deployed server answers every request with 421 until you list its real hostname; list both the bare host and the &lt;code&gt;:*&lt;/code&gt; form. &lt;code&gt;allowed_origins&lt;/code&gt; only matters for browser clients.&lt;/p&gt;

&lt;p&gt;If TLS ends at a proxy in front of uvicorn, add &lt;code&gt;--proxy-headers&lt;/code&gt; and set &lt;code&gt;--forwarded-allow-ips&lt;/code&gt; to the proxy's address, or to &lt;code&gt;'*'&lt;/code&gt; when nothing but the proxy can reach uvicorn; otherwise the app's redirects point at &lt;code&gt;http://&lt;/code&gt; and clients refuse them. Clients on the current protocol revision (2026-07-28) send self-contained requests, so any worker can answer any of them. Current Claude Code speaks that revision. Older clients (2025-11-25 and earlier) keep a session in one worker's memory, so they need sticky sessions, or &lt;code&gt;stateless_http=True&lt;/code&gt; on &lt;code&gt;streamable_http_app()&lt;/code&gt;, which drops sessions and server-to-client features such as sampling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authentication: check a token before any tool runs
&lt;/h2&gt;

&lt;p&gt;A remote server without auth is a public API to your data. The SDK's integration point is a token verifier:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;secrets&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AnyHttpUrl&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.server.auth.provider&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AccessToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TokenVerifier&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.server.auth.settings&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AuthSettings&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EnvTokenVerifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TokenVerifier&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;AccessToken&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MCP_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;secrets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compare_digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;AccessToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;internal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scopes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders:read&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;


&lt;span class="n"&gt;mcp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MCPServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;token_verifier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;EnvTokenVerifier&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;AuthSettings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;issuer_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;AnyHttpUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://auth.example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;resource_server_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;AnyHttpUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://mcp.example.com/mcp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;required_scopes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders:read&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that in place, a request without a valid token gets a 401 whose &lt;code&gt;WWW-Authenticate&lt;/code&gt; header points to the server's protected-resource metadata, which the SDK serves for you at &lt;code&gt;/.well-known/oauth-protected-resource/mcp&lt;/code&gt;. Clients that support OAuth follow it to your identity provider.&lt;/p&gt;

&lt;p&gt;A shared token is a sensible first step for an internal tool, not the last. The next step is verifying tokens your identity provider issues to each user, so that every tool call runs with that person's permissions. The specification also forbids passing the client's token straight through to your backend; the server should hold its own credentials for the API, scoped to what the tools need.&lt;/p&gt;

&lt;p&gt;I checked this exact code with version 2.3.0 of the SDK: without a token the server answered 401 with the metadata link, a wrong token was refused, and with the right token a client listed the tool and called it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploying to Cloud Run
&lt;/h2&gt;

&lt;p&gt;Any platform that runs a Python web app works. Cloud Run suits a small team server because it scales to zero when idle. A Dockerfile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; python:3.12-slim&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; requirements.txt .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-cache-dir&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; server.py .&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; exec uvicorn server:app --host 0.0.0.0 --port ${PORT:-8080} --proxy-headers --forwarded-allow-ips='*'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store the token in Secret Manager, then deploy from the source directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud run deploy orders-mcp &lt;span class="nt"&gt;--source&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--region&lt;/span&gt; us-central1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--allow-unauthenticated&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set-secrets&lt;/span&gt; &lt;span class="nv"&gt;MCP_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;orders-mcp-token:latest &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set-env-vars&lt;/span&gt; &lt;span class="nv"&gt;API_BASE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://api.example.com/v1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only Cloud Run's front end can reach the container, so the &lt;code&gt;CMD&lt;/code&gt; trusts its forwarded headers. &lt;code&gt;--allow-unauthenticated&lt;/code&gt; looks alarming, but it only switches off Cloud Run's own Google-identity check; your token verifier is the gate. Put the service URL that Cloud Run prints into &lt;code&gt;allowed_hosts&lt;/code&gt; and &lt;code&gt;resource_server_url&lt;/code&gt;, or map your own domain first and use that.&lt;/p&gt;

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

&lt;p&gt;For a low-traffic internal server, usually nothing. Cloud Run's free tier with request-based billing includes 2 million requests, 180,000 vCPU-seconds and 360,000 GiB-seconds a month, per billing account. A team tool serving a few thousand calls a day stays well inside it.&lt;/p&gt;

&lt;p&gt;The surprise is cold starts: a server scaled to zero takes a few seconds to wake. Keeping one instance warm fixes that, but idle time is then billed. The model's tokens for tool definitions and results usually cost more than the server, one more reason to keep the tool list short.&lt;/p&gt;

&lt;h2&gt;
  
  
  PostgreSQL MCP servers: start read-only
&lt;/h2&gt;

&lt;p&gt;The tempting design is one &lt;code&gt;run_sql&lt;/code&gt; tool. Start instead with a few named, read-only tools over queries you wrote, a database role that can only read, and a row limit. The read-only role is the one guard that doesn't rely on the model behaving.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we learnt building MCP servers
&lt;/h2&gt;

&lt;p&gt;We put one in front of our own APIs; &lt;a href="https://nulltensor.com/posts/mcp-vs-api/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=mcp-vs-api" rel="noopener noreferrer"&gt;MCP vs API&lt;/a&gt; has the longer story:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fewer, task-shaped tools.&lt;/strong&gt; Our first version mapped endpoints to tools one to one and exposed around 150 functions. The agent was poor at choosing among them. A handful of tools shaped like the questions people actually ask worked far better.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read before write.&lt;/strong&gt; Start with read-only tools, and add writes one at a time, each narrowly scoped and confirmed by the user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The user's permissions, not the server's.&lt;/strong&gt; A tool call made for a person should see only what that person can see in your app. Design for this from the start; retrofitting it is painful.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Descriptions are the interface.&lt;/strong&gt; The model only knows a tool through its name, schema and docstring. When an agent picks the wrong tool, improve the description before you add code.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to build an MCP server: common questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How do I build an MCP server?&lt;/strong&gt; Install the official SDK, create an MCPServer, and turn each capability into a decorated Python function with type hints and a clear docstring. Run it over stdio for local use, or as a streamable HTTP app for remote use, then connect it from Claude Code with claude mcp add and call one tool to check it works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How can I host my own MCP server?&lt;/strong&gt; Any platform that runs a Python web app will do, because a remote MCP server is an ASGI app on one URL path. A container on Cloud Run, Fly or a small VM all work. Set the allowed hostnames, put a token check in front of the tools, and keep secrets in the platform's secret store.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I build a remote MCP server?&lt;/strong&gt; Use the streamable HTTP transport instead of stdio. With the Python SDK, call streamable_http_app() and serve the result with uvicorn. The server then answers at one path, /mcp by default, so any MCP client that can reach the URL can connect. Add a token verifier before you expose it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How much does an MCP server cost to run?&lt;/strong&gt; For a low-traffic internal server, often nothing. Google Cloud Run's free tier includes 2 million requests and 180,000 vCPU-seconds a month with request-based billing, which covers most team tools. Cost appears when you keep instances warm to avoid cold starts, because idle time is then billed too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should a PostgreSQL MCP server run raw SQL?&lt;/strong&gt; Not at first. Give the model a few named, read-only tools, such as find_customer or recent_orders, that run queries you wrote, and connect with a database role that can only read. Raw SQL is tempting for analysis, but it hands the model your whole schema and every mistake it can make.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to start
&lt;/h2&gt;

&lt;p&gt;Build one read-only tool for the question your team asks most, run it locally for a week, and only then make it remote. Once it is connected, a Skill can teach Claude how your team uses it; &lt;a href="https://nulltensor.com/posts/claude-skills-might-be-bigger-than/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=claude-skills-might-be-bigger-than" rel="noopener noreferrer"&gt;Claude Skills vs MCP servers&lt;/a&gt; explains how the two fit together.&lt;/p&gt;

&lt;p&gt;What is the first tool you would expose?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://nulltensor.com/posts/how-to-build-an-mcp-server/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=how-to-build-an-mcp-server" rel="noopener noreferrer"&gt;nulltensor.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>python</category>
      <category>ai</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Claude MCP: What It Is and How to Add a Server</title>
      <dc:creator>Rohan Sen Sharma</dc:creator>
      <pubDate>Sun, 11 Oct 2026 03:00:00 +0000</pubDate>
      <link>https://dev.to/rss_holmes/claude-mcp-what-it-is-and-how-to-add-a-server-4ol3</link>
      <guid>https://dev.to/rss_holmes/claude-mcp-what-it-is-and-how-to-add-a-server-4ol3</guid>
      <description>&lt;p&gt;&lt;strong&gt;Claude MCP is Claude connected to other systems through the Model Context Protocol, Anthropic's open standard for giving AI assistants tools and data.&lt;/strong&gt; You add an MCP server, the server exposes what one system can do, and Claude can then read from it and act in it. In Claude Code, adding one is a single &lt;code&gt;claude mcp add&lt;/code&gt; command.&lt;/p&gt;

&lt;p&gt;That is the short version. The rest of this post covers what an MCP server actually gives Claude, how to add one in each Claude app, where the configuration lives, and the few security habits worth having from the first day.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Claude MCP server gives Claude
&lt;/h2&gt;

&lt;p&gt;An MCP server is a small program that speaks the protocol and offers three kinds of things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tools:&lt;/strong&gt; actions Claude can call, such as "create an issue" or "run this query".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resources:&lt;/strong&gt; data Claude can read, such as a file, a table or a page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prompts:&lt;/strong&gt; reusable templates the server suggests for common tasks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Servers run in one of two ways. A &lt;strong&gt;local&lt;/strong&gt; server is a process on your own machine that Claude starts and talks to over standard input and output (stdio). A &lt;strong&gt;remote&lt;/strong&gt; server runs somewhere else and Claude reaches it over HTTP. Local is simpler for your own files and tools; remote suits services you sign in to, and it works from claude.ai in a browser, where nothing can run on your machine.&lt;/p&gt;

&lt;p&gt;Anthropic released MCP as an open standard in November 2024, so the same server works with Claude Code, Claude Desktop, claude.ai and other MCP clients. Whether a system needs its own MCP server, or whether the API it already has is enough, is a separate decision; I worked through it in &lt;a href="https://nulltensor.com/posts/mcp-vs-api/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=mcp-vs-api" rel="noopener noreferrer"&gt;MCP vs API&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to add an MCP server in Claude Code
&lt;/h2&gt;

&lt;p&gt;The command is &lt;code&gt;claude mcp add&lt;/code&gt;. For a local server, everything after the two dashes is the command Claude Code runs to start it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add filesystem &lt;span class="nt"&gt;--&lt;/span&gt; npx &lt;span class="nt"&gt;-y&lt;/span&gt; @modelcontextprotocol/server-filesystem ~/projects
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pass secrets as environment variables, not as arguments, with &lt;code&gt;--env&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;claude mcp add &lt;span class="nt"&gt;--env&lt;/span&gt; &lt;span class="nv"&gt;AIRTABLE_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-key airtable &lt;span class="nt"&gt;--&lt;/span&gt; npx &lt;span class="nt"&gt;-y&lt;/span&gt; airtable-mcp-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a remote server, give the transport and the URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add &lt;span class="nt"&gt;--transport&lt;/span&gt; http notion https://mcp.notion.com/mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remote servers usually sign you in with OAuth. Open a Claude Code session, type &lt;code&gt;/mcp&lt;/code&gt;, pick the server and finish the login in the browser. A server that takes a fixed token instead accepts a header: &lt;code&gt;--header "Authorization: Bearer your-token"&lt;/code&gt;. The older SSE transport still works but is deprecated; use HTTP where a server offers it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scope: who gets the server
&lt;/h3&gt;

&lt;p&gt;Every server is added at one of three scopes, set with &lt;code&gt;--scope&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;th&gt;Flag&lt;/th&gt;
&lt;th&gt;Stored in&lt;/th&gt;
&lt;th&gt;Who sees it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Local (default)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--scope local&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;~/.claude.json&lt;/code&gt;, under this project&lt;/td&gt;
&lt;td&gt;Only you, only in this project&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Project&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--scope project&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.mcp.json&lt;/code&gt; in the project root&lt;/td&gt;
&lt;td&gt;Everyone who clones the repository&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--scope user&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;~/.claude.json&lt;/code&gt;, top level&lt;/td&gt;
&lt;td&gt;Only you, in every project&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Use &lt;strong&gt;project&lt;/strong&gt; scope for servers the whole team needs, such as the issue tracker for that repository, and commit the file. Use &lt;strong&gt;user&lt;/strong&gt; scope for personal tools you want everywhere. Leave one-off experiments at the default.&lt;/p&gt;

&lt;h3&gt;
  
  
  The .mcp.json file (Claude MCP JSON config)
&lt;/h3&gt;

&lt;p&gt;Project-scoped servers live in &lt;code&gt;.mcp.json&lt;/code&gt;, and you can write it by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"issues"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://example.com/mcp"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"database"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stdio"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/path/to/db-server"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"--read-only"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"DB_URL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"${DB_URL}"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;${VAR}&lt;/code&gt; expands from your environment, so the committed file holds no secrets; &lt;code&gt;${VAR:-default}&lt;/code&gt; supplies a fallback. To add a server from a JSON snippet without editing files, use &lt;code&gt;claude mcp add-json &amp;lt;name&amp;gt; '&amp;lt;json&amp;gt;'&lt;/code&gt;. If you already set servers up in Claude Desktop, &lt;code&gt;claude mcp add-from-claude-desktop&lt;/code&gt; imports them on macOS and WSL.&lt;/p&gt;

&lt;h3&gt;
  
  
  Listing and removing servers
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp list            &lt;span class="c"&gt;# every configured server&lt;/span&gt;
claude mcp get issues      &lt;span class="c"&gt;# details for one&lt;/span&gt;
claude mcp remove issues   &lt;span class="c"&gt;# delete it (add --scope project for .mcp.json)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside a session, &lt;code&gt;/mcp&lt;/code&gt; shows which servers connected and which failed, and lets you switch one off without deleting it. That is the quickest fix when a server is misbehaving and you want it out of the way for an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude Desktop and claude.ai
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Claude Desktop&lt;/strong&gt; reads local servers from &lt;code&gt;claude_desktop_config.json&lt;/code&gt;. Open it from the Claude menu: Settings, then Developer, then Edit Config. It lives at &lt;code&gt;~/Library/Application Support/Claude/claude_desktop_config.json&lt;/code&gt; on macOS and &lt;code&gt;%APPDATA%\Claude\claude_desktop_config.json&lt;/code&gt; on Windows, and uses the same &lt;code&gt;mcpServers&lt;/code&gt; shape as &lt;code&gt;.mcp.json&lt;/code&gt;. Quit and reopen the app after editing it; Desktop only reads the file at start-up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;claude.ai&lt;/strong&gt; in the browser cannot start local processes, so it uses remote servers only, which it calls connectors. Open Settings, choose Connectors, click Add and then "Add custom connector", paste the server URL and sign in. The connector's settings let you turn individual tools on or off, which is worth doing for anything that can write.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security in three habits
&lt;/h2&gt;

&lt;p&gt;An MCP server runs with real credentials and, for local servers, with your user account's permissions. Three habits cover most of the risk:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Least privilege.&lt;/strong&gt; Give a server the narrowest scope that does the job: specific folders, a read-only database user, a token limited to one repository.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trust before you connect.&lt;/strong&gt; Anthropic's own docs warn that servers which fetch outside content can expose you to prompt injection. Install servers from their publisher, not from a random fork.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Allow-list the risky tools.&lt;/strong&gt; Leave read tools on, and keep tools that delete, send or pay behind a confirmation, or switched off.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The design side of this, deciding which actions a server should expose at all, is most of what &lt;a href="https://nulltensor.com/posts/mcp-vs-api/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=mcp-vs-api" rel="noopener noreferrer"&gt;MCP vs API&lt;/a&gt; is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude MCP vs Skills
&lt;/h2&gt;

&lt;p&gt;MCP gives Claude access to a system; a Skill teaches it a procedure. The two fit together: the server fetches the data, the Skill says what to do with it. For the full comparison, including when to reach for each, see &lt;a href="https://nulltensor.com/posts/claude-skills-might-be-bigger-than/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=claude-skills-might-be-bigger-than" rel="noopener noreferrer"&gt;Claude Skills vs MCP servers&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude MCP: common questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is Claude MCP?&lt;/strong&gt; Claude MCP is Claude using the Model Context Protocol, Anthropic's open standard for connecting AI assistants to other systems. Claude connects to MCP servers, and each server exposes tools, resources or prompts from one system, such as GitHub or a database, so Claude can read live data and take actions there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I add an MCP server to Claude Code?&lt;/strong&gt; Run claude mcp add. For a local server, put the launch command after two dashes: claude mcp add my-server -- npx -y some-mcp-server. For a remote server, pass the transport and URL: claude mcp add --transport http my-server &lt;a href="https://example.com/mcp" rel="noopener noreferrer"&gt;https://example.com/mcp&lt;/a&gt;. Then run /mcp inside Claude Code to check it connected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where is the Claude MCP config file?&lt;/strong&gt; It depends on the app. Claude Code keeps local and user servers in ~/.claude.json and shared project servers in .mcp.json at the project root. Claude Desktop uses claude_desktop_config.json, under ~/Library/Application Support/Claude on macOS and %APPDATA%\Claude on Windows. claude.ai stores connectors in your account settings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I remove an MCP server from Claude?&lt;/strong&gt; In Claude Code, run claude mcp remove followed by the server name, adding --scope project if it lives in .mcp.json. To pause a server without deleting it, open /mcp and turn it off. In Claude Desktop, delete its entry from the config file and restart the app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the difference between Claude MCP and Claude Skills?&lt;/strong&gt; MCP gives Claude access to another system; a Skill teaches Claude a procedure. An MCP server fetches the customer record, and a Skill says how to turn it into your standard report. They solve different problems, and most real workflows end up using both together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to start
&lt;/h2&gt;

&lt;p&gt;Pick one system you already open every day, add its server at local scope, and give Claude one real task in it. If the server earns its place, move it to project scope so the rest of the team gets it too.&lt;/p&gt;

&lt;p&gt;Which system would you connect first?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://nulltensor.com/posts/what-is-claude-mcp/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=what-is-claude-mcp" rel="noopener noreferrer"&gt;nulltensor.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claude</category>
      <category>mcp</category>
      <category>ai</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>My Claude Code chief of staff: one VM, eight specialists and one Telegram chat</title>
      <dc:creator>Rohan Sen Sharma</dc:creator>
      <pubDate>Sat, 10 Oct 2026 03:00:00 +0000</pubDate>
      <link>https://dev.to/rss_holmes/my-claude-code-chief-of-staff-one-vm-eight-specialists-and-one-telegram-chat-25d8</link>
      <guid>https://dev.to/rss_holmes/my-claude-code-chief-of-staff-one-vm-eight-specialists-and-one-telegram-chat-25d8</guid>
      <description>&lt;p&gt;For the last week, most of my work outside meetings has gone through one conversation. I send a text or a voice note to a Claude Code session on Telegram, and it hands the work to one of eight other Claude sessions, each with its own job. By evening I have an experiment measured, an inbox turned into a task list and a research note, and I have not opened a terminal.&lt;/p&gt;

&lt;p&gt;That sounds tidier than it has been. The setup broke in new ways almost every day, and most of what I know about it came from those breaks. This post walks through the agent architecture, what it costs and the failures that shaped it, at the level of detail where you could build your own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the system
&lt;/h2&gt;

&lt;p&gt;Everything runs on one Azure VM with a permanently logged-in desktop, because some work needs a real browser. On it run three kinds of process:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A chief of staff.&lt;/strong&gt; One long-running Claude Code session I talk to. I named it Elaine, mostly so voice notes would sound like a person rather than a process. It never does the work itself. It routes each task to a specialist and relays the result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Specialists.&lt;/strong&gt; Background Claude Code sessions (&lt;code&gt;claude --bg --name &amp;lt;role&amp;gt;&lt;/code&gt;), each with a written brief: content, SEO, video, inbox triage, a sysadmin for the VM itself, and engineers for the product repositories. They are separate processes rather than subagents, so they survive the chief of staff restarting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timer loops.&lt;/strong&gt; systemd user timers that run headless &lt;code&gt;claude -p&lt;/code&gt; jobs on a schedule, with no conversation at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp0z6029parwp4c0dqilb.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp0z6029parwp4c0dqilb.jpg" alt="Flowchart: Telegram to the chief of staff, which delegates to six specialists; a watchdog restarts it; timers run three loops that report to the same chat; long reads go to Notion; memory is shared by all sessions" width="799" height="449"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the chief of staff never does the work
&lt;/h2&gt;

&lt;p&gt;The first version let the chief of staff help with small tasks. Claude Code handles one turn at a time, so every minute it spent grepping a log was a minute my next request sat in a queue.&lt;/p&gt;

&lt;p&gt;So the rule became strict. On any incoming task it makes at most one quick read to decide who should do it, sends the brief to that specialist, and ends its turn. If no specialist fits, it launches one. Verification is delegated too: a claim of "done" gets a request for evidence or a second specialist's check.&lt;/p&gt;

&lt;p&gt;A side benefit I did not plan for: every specialist's context stays about its own domain. The content session knows the content experiment ledger in detail and nothing about database migrations, which keeps its turns short and its mistakes easy to trace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Telegram as the only interface
&lt;/h2&gt;

&lt;p&gt;I am rarely at the VM's terminal, so anything that shows up only there never reaches me. &lt;a href="https://code.claude.com/docs/en/channels" rel="noopener noreferrer"&gt;Claude Code's channels feature&lt;/a&gt; connects a session to a Telegram bot, and that bot became the only way the system talks to me.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Voice notes both ways.&lt;/strong&gt; Incoming voice notes are transcribed with a speech-to-text deployment on Azure, and the chief of staff can reply as a native voice bubble, all for about a dollar a month. The one trap: Telegram sends &lt;code&gt;.oga&lt;/code&gt; files, and the endpoint rejects that extension until you rename it to &lt;code&gt;.ogg&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tappable decisions.&lt;/strong&gt; A decision arrives as a reply keyboard with the options as buttons in Telegram. Inline buttons looked nicer, but their taps never reached the session, because the plugin forwards messages, not callbacks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One chat, long reads elsewhere.&lt;/strong&gt; Every message names its project, and anything long, such as a draft or a plan, goes to a Notion page with a link in chat. I briefly tried one topic per project but for one person and one assistant it cost more attention than it saved.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The watchdog, and what it taught me about Telegram
&lt;/h2&gt;

&lt;p&gt;Telegram allows exactly one poller per bot token, and the Claude Code plugin enforces that by killing any existing poller when a new one starts. That is reasonable for one session and dangerous for a machine running nine. In the first two days the Telegram bridge went down four ways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Another session started the plugin&lt;/strong&gt;, killed the chief of staff's poller and took over, so my messages arrived in a specialist's session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A health check spawned a second copy.&lt;/strong&gt; &lt;code&gt;claude mcp list&lt;/code&gt; starts each MCP server to check it, which starts a second poller, which kills the live one and then exits. The result was no poller at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deleting old sessions&lt;/strong&gt; in the agent view killed the live poller within a minute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idle retirement&lt;/strong&gt; stopped the session after about an hour; pinning it exempts it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The watchdog is a systemd timer that runs a short shell script every two minutes: 30 to 40 milliseconds of CPU and about 5 MB of memory per healthy tick. It checks that the poller is alive, runs from the plugin's directory, and has the chief of staff session as its ancestor. That last check came from failure 1: for three ticks the poller was alive and healthy and belonged to the wrong session. "A poller exists" and "my session owns the poller" are different health signals, and only the second tells you whether messages will reach you.&lt;/p&gt;

&lt;p&gt;After two failed checks it restarts the chief of staff, with a lock and cool-downs. The restart has its own trap: &lt;code&gt;claude --bg --resume &amp;lt;session-id&amp;gt;&lt;/code&gt; wakes the session in place only with no other flags. Pass any flag, even the original ones, and you get a copy with a new id, no name and no Telegram channel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timer loops: move the deterministic work out of the model
&lt;/h2&gt;

&lt;p&gt;Each loop runs in two stages. The first is plain bash with no model: check whether anything is due, gather the inputs, and exit if there is nothing to do. Only then does the runner start &lt;code&gt;claude -p&lt;/code&gt; on a cheaper model, with the inputs already in the prompt.&lt;/p&gt;

&lt;p&gt;The lesson we kept relearning: anything deterministic belongs in a script, not in the model's instructions.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A headless run cannot call &lt;code&gt;sleep&lt;/code&gt;, so waiting lives in a helper.&lt;/li&gt;
&lt;li&gt;A browser helper re-reads the page before any retry, so it never submits twice.&lt;/li&gt;
&lt;li&gt;A skill file must never point at memory: a headless run from another directory loads none, and the reference silently does nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Memory as markdown files
&lt;/h2&gt;

&lt;p&gt;Every session reads a shared directory of markdown files, one fact per file with a name, a one-line description and a type, linked with &lt;code&gt;[[name]]&lt;/code&gt; into a small graph. A one-line-per-memory index loads into every session at start.&lt;/p&gt;

&lt;p&gt;Two rules keep it from rotting: specialists may correct facts in their own domain but must say so, and nobody deletes a memory without the chief of staff agreeing, because a stale-looking memory is often the only record of why a rule exists. The weak spot was the decision log, which grew to 188 KB in five days and was re-read at every session start. It is now a short live queue plus an archive.&lt;/p&gt;

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

&lt;p&gt;On one day early on, the Anthropic dashboard showed about $195, and I did not know why. The audit found that 88% of spend was the model re-reading its own context: 48% cache reads and 40% cache writes, with output under 12%. The chief of staff was re-reading around 440,000 tokens on every turn, because on a 1M-token window auto-compaction never triggered.&lt;/p&gt;

&lt;p&gt;What helped was context and turns, not clever prompts. A standard window cut the chief of staff's cost per turn from about 28 cents to 13. It runs on Claude Opus 5.5, and I capped its context window by hand at 256,000 tokens, so auto-compaction kicks in early, keeping turns cheaper and the thread responsive. The specialists run on Claude Opus 5.5 too, the scheduled jobs on Claude Sonnet 5.5 at a few cents a turn, and nothing uses Fable. And one complete brief per task, because cost follows turns more closely than anything else we measured. It is still not cheap: daily spend ranges from about $30 on a quiet day to about $200 on a heavy build day.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failures worth copying
&lt;/h2&gt;

&lt;p&gt;Most bugs in this system failed quietly: clean logs, exit code 0, and nothing happened.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stale checkouts.&lt;/strong&gt; The timers run scripts from each repository's main checkout, while specialists work in separate git worktrees. One day five fixes were pushed and none of them ran for five hours, because nobody had fast-forwarded the main checkout. Every report now states that the main checkout matches the remote.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rules that read correctly.&lt;/strong&gt; Before one loop went live, simulating every timer fire of its first day against the real data found four bugs, including a lock the loop could never acquire. Each read fine on paper and would have exited 0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checks that confirmed instead of tested.&lt;/strong&gt; Grepping a log for the lines you expect confirms your expectation; it does not test the code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A test that was not a test.&lt;/strong&gt; A dry run once sent me a real notification because only the model call was stubbed. Runners now have a switch that silences every side channel.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The habit behind all of these: we do not trust a rule until it has run against the real state, and we do not trust a report until the result is on disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I built my own
&lt;/h2&gt;

&lt;p&gt;There are good off-the-shelf options: xAI's Grok Bot, Meta's Muse, OpenAI's Codex, Anthropic's Claude Cowork, and open-source alternatives to each. Every one of them held me back in some way.&lt;/p&gt;

&lt;p&gt;I wanted something wired completely around how I work. A custom build lets me take the best of each tool and change the setup whenever I need to without switching providers.&lt;/p&gt;

&lt;p&gt;This is not the final build, and I am excited about what I will build into my personal assistant next.&lt;/p&gt;

&lt;p&gt;If you built your own, what is the first thing you would wire it into?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://nulltensor.com/posts/claude-code-chief-of-staff-telegram/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=claude-code-chief-of-staff-telegram" rel="noopener noreferrer"&gt;nulltensor.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claude</category>
      <category>ai</category>
      <category>agents</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Claude Code Review in Production: How Our Review Rules Evolved Over Five Months</title>
      <dc:creator>Rohan Sen Sharma</dc:creator>
      <pubDate>Fri, 09 Oct 2026 03:00:00 +0000</pubDate>
      <link>https://dev.to/rss_holmes/claude-code-review-in-production-how-our-review-rules-evolved-over-five-months-1ehb</link>
      <guid>https://dev.to/rss_holmes/claude-code-review-in-production-how-our-review-rules-evolved-over-five-months-1ehb</guid>
      <description>&lt;p&gt;Claude Code review started at my company as a single prompt in a GitHub workflow, and five months later it is a playbook, six specialist agents and a verifier that throws out any finding it cannot prove. Almost every step between those two points was a reaction to something that went wrong: false positives while the rules were still settling, findings on code the pull request never touched, severity that depended on the model's mood, and a reviewer that invented problems when we gave it too many rules at once.&lt;/p&gt;

&lt;p&gt;This is how the rules evolved and what each change fixed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude Code's built-in review, and why we wrote our own rules
&lt;/h2&gt;

&lt;p&gt;Claude Code now has a review of its own. Anthropic's managed &lt;a href="https://code.claude.com/docs/en/code-review" rel="noopener noreferrer"&gt;Code Review&lt;/a&gt;, in research preview for Team and Enterprise plans, runs through the Claude GitHub App: several agents look for different classes of issue in parallel, a verification step filters out false positives, and findings arrive as inline comments tagged Important, Nit or Pre-existing. It never approves or blocks a pull request, and you tune it with a &lt;code&gt;REVIEW.md&lt;/code&gt; file. Locally, &lt;code&gt;/code-review&lt;/code&gt; does the same for your branch.&lt;/p&gt;

&lt;p&gt;We kept our own because we already had a customised flow. The built-in review hunts for correctness bugs by default; ours enforces a written architecture, cites a rule for every finding, and votes on the pull request. If you want a second pair of eyes for bugs, start there; if you want documented rules enforced with a verdict, you need a playbook of your own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version 1: one prompt, run on request (April)
&lt;/h2&gt;

&lt;p&gt;The first version went in on 20 April. A workflow listened for a pull request comment containing "&lt;a class="mentioned-user" href="https://dev.to/claude"&gt;@claude&lt;/a&gt;" and "review", and ran Claude Code with one prompt that did three things.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For files in our layered modules (routers, services, repositories and DTOs), it read the architecture rule documents for each layer and treated any violation as &lt;strong&gt;blocking&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Everything else got a general review, explicitly without the layering rules.&lt;/li&gt;
&lt;li&gt;It grouped findings by file, each with a severity (BLOCKING, WARNING or SUGGESTION), a location, the problem and a concrete fix.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That structure survived every later version; the trigger did not. While the rules were settling it produced false positives, so it ran only when someone asked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version 2: every pull request, with a verdict (May)
&lt;/h2&gt;

&lt;p&gt;Between 12 and 15 May the workflow changed four times.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It ran on every pull request&lt;/strong&gt;, on open, on each new push and on reopen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It submitted a real GitHub review&lt;/strong&gt;: &lt;em&gt;request changes&lt;/em&gt; if there was any blocking finding, &lt;em&gt;approve&lt;/em&gt; otherwise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It skipped draft pull requests&lt;/strong&gt; until they were marked ready for review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It skipped long-lived project branches&lt;/strong&gt;, where every intermediate push would have drawn a review.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lesson of that week: once a reviewer votes, noise stops being a nuisance and becomes a cost. A wrong "request changes" blocks a colleague. Everything that followed was about precision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version 3: load only the rules that matter, and review only what changed
&lt;/h2&gt;

&lt;p&gt;The next rewrite introduced three ideas that are still the backbone of the system.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Load rules by path, not all at once.&lt;/strong&gt; The reviewer lists the changed files, then reads only the rule documents those paths need: router rules only if a router changed, and so on. Loading every rule at once caused hallucinations: the reviewer reported violations that were not there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Review only changed lines.&lt;/strong&gt; The prompt gained a sentence we have never removed: &lt;em&gt;review only changed or added lines, never flag pre-existing code&lt;/em&gt;. Without it, a one-line fix in an old file could come back with twenty findings about code written years earlier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Severity by definition, not judgement.&lt;/strong&gt; The severity section was headed "apply exactly, no judgment calls". BLOCKING meant a layering-rule violation, a correctness bug, a security issue or a data-loss risk. WARNING meant correct architecture that was still risky. SUGGESTION meant style or naming that broke no rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version 4: one playbook for local and CI reviews
&lt;/h2&gt;

&lt;p&gt;On 18 May the rules moved out of the workflow and out of &lt;code&gt;CLAUDE.md&lt;/code&gt; into a single &lt;code&gt;docs/review.md&lt;/code&gt;. Both the GitHub Action and a local &lt;code&gt;/review&lt;/code&gt; command follow the same file, so a local review before pushing applies the same rules the pull request will face.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ask for the base branch.&lt;/strong&gt; Locally, the review asks which branch the work will merge into instead of assuming &lt;code&gt;main&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skip data files.&lt;/strong&gt; SQL, CSV and JSON files are excluded; reviewing migrations and fixtures as code produced findings nobody could act on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every finding cites a rule.&lt;/strong&gt; BLOCKING must cite an architecture rule file and section, WARNING a style rule from &lt;code&gt;CLAUDE.md&lt;/code&gt;. SUGGESTION is for anything no rule covers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never block legacy code on style.&lt;/strong&gt; Style caps at WARNING everywhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tying severity to a citation changed the character of the reviews. A finding either points at a rule the team agreed, or it is a suggestion the author can ignore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version 5: specialists and a verifier
&lt;/h2&gt;

&lt;p&gt;On 19 May the single reviewer became an &lt;strong&gt;orchestrator&lt;/strong&gt; that reviews nothing itself. It routes each changed file to the specialists that own it, runs them in parallel and assembles the result.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Specialist&lt;/th&gt;
&lt;th&gt;Reviews&lt;/th&gt;
&lt;th&gt;Owns&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Router reviewer&lt;/td&gt;
&lt;td&gt;API and router files in the layered modules&lt;/td&gt;
&lt;td&gt;Router-layer rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service reviewer&lt;/td&gt;
&lt;td&gt;Service files in the layered modules&lt;/td&gt;
&lt;td&gt;Service-layer rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Repository reviewer&lt;/td&gt;
&lt;td&gt;Repository files in the layered modules&lt;/td&gt;
&lt;td&gt;Repository-layer rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Architecture reviewer&lt;/td&gt;
&lt;td&gt;Any file in the layered modules&lt;/td&gt;
&lt;td&gt;Cross-cutting architecture rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python style reviewer&lt;/td&gt;
&lt;td&gt;Every Python file&lt;/td&gt;
&lt;td&gt;Style rules from &lt;code&gt;CLAUDE.md&lt;/code&gt;, capped at WARNING&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verifier&lt;/td&gt;
&lt;td&gt;Every finding&lt;/td&gt;
&lt;td&gt;Nothing: it only checks the others' work&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each specialist reads only its own rule document, so a service file reviewed by four specialists does not get the same finding four times. They all return findings in one fixed format, with the offending code quoted verbatim.&lt;/p&gt;

&lt;p&gt;The verifier is the Claude Code review agent I would copy first. For every finding it checks four things, and rejects the finding if any fails:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The cited file exists.&lt;/li&gt;
&lt;li&gt;The quoted code appears within five lines of the cited line.&lt;/li&gt;
&lt;li&gt;That line was added or changed in this diff.&lt;/li&gt;
&lt;li&gt;The cited rule section exists and says what the finding claims.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Its instructions open with the principle behind the whole design: &lt;em&gt;false positives are far worse than false negatives&lt;/em&gt;. It may not re-grade severity or add findings of its own, and rejected findings are dropped silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding a specialist without touching the workflow (September)
&lt;/h2&gt;

&lt;p&gt;In September we added a sixth reviewer for &lt;strong&gt;database performance&lt;/strong&gt;, because our most expensive production problems are queries that are fast in development and slow at scale. It blocks queries inside loops, N+1 access to related fields and per-row writes where a batch write exists, and warns on unbounded fetches and aggregation done in Python instead of the database. Where a pattern is deliberate, a &lt;code&gt;# db-perf: allow&lt;/code&gt; comment with the reason suppresses the finding.&lt;/p&gt;

&lt;p&gt;Two decisions stand out. Database performance findings can block &lt;strong&gt;legacy&lt;/strong&gt; code too, the one deliberate exception to "never block legacy", because a query in a loop costs the same wherever it lives. And the workflow file did not change at all: adding a specialist meant one rule document, one agent definition and one row in the routing table.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we would tell another team
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start with the output format and the severity definitions.&lt;/strong&gt; They survived every version; the trigger and the architecture did not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make every finding cite a rule.&lt;/strong&gt; It turns arguments about taste into a conversation about whether the rule is right.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never flag pre-existing code.&lt;/strong&gt; Nothing destroys trust in an automated reviewer faster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify before you post.&lt;/strong&gt; A second pass that checks the quote, the line, the diff and the rule is cheap compared with a wrong "request changes".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This connects to &lt;a href="https://nulltensor.com/posts/six-feedback-loops-ai-first-engineering-pipeline/" rel="noopener noreferrer"&gt;six feedback loops for AI coding agents&lt;/a&gt;: the review stage only improves the next change if its findings are precise enough to act on, and it guards against the problem in &lt;a href="https://nulltensor.com/posts/ai-generated-test-coverage/" rel="noopener noreferrer"&gt;why generated tests miss bugs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude Code review: common questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can Claude Code PR review run automatically on every pull request?&lt;/strong&gt; Yes. It runs in GitHub Actions through Anthropic's official action, triggered on pull request events or by an "&lt;a class="mentioned-user" href="https://dev.to/claude"&gt;@claude&lt;/a&gt;" comment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude Code review skill, command or agent: which should I use?&lt;/strong&gt; They fit together: a slash command as the local entry point, sub-agents so each reviewer holds one set of rules, and a skill for the reusable procedure. To package ours as a skill, put the steps in a &lt;code&gt;SKILL.md&lt;/code&gt; with the rule documents and finding format beside it, and keep the specialists and verifier as sub-agents. &lt;a href="https://nulltensor.com/posts/getting-started-with-claude-skills/" rel="noopener noreferrer"&gt;How to create Claude Skills&lt;/a&gt; walks through the structure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does it replace human review?&lt;/strong&gt; No. It catches rule violations reliably when the rules are written down and cited, which is most of what a human reviewer spends time on, but it is weaker at judging whether a change is the right change. A human approval is still required on every pull request. Review effort is also the cost headline numbers hide: &lt;a href="https://nulltensor.com/posts/agent-pr-merge-rate/" rel="noopener noreferrer"&gt;a 90% agent PR merge rate can still describe a weak workflow&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves us
&lt;/h2&gt;

&lt;p&gt;The system we run today is not clever. It is a playbook, a routing table, a handful of reviewers that each know one set of rules, and a verifier that refuses anything it cannot prove. Every piece exists because a simpler version failed in a specific way, and if you are starting now you can skip most of those failures.&lt;/p&gt;

&lt;p&gt;The playbook itself, with our company-specific rules removed, is here: &lt;a href="https://nulltensor.com/notes/claude-code-review-template/" rel="noopener noreferrer"&gt;a Claude Code review template you can copy&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://nulltensor.com/posts/claude-code-review/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=claude-code-review" rel="noopener noreferrer"&gt;nulltensor.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claude</category>
      <category>ai</category>
      <category>codereview</category>
      <category>productivity</category>
    </item>
    <item>
      <title>MCP vs API: When to Build an MCP Server, and When the API Is Enough</title>
      <dc:creator>Rohan Sen Sharma</dc:creator>
      <pubDate>Thu, 08 Oct 2026 03:00:00 +0000</pubDate>
      <link>https://dev.to/rss_holmes/mcp-vs-api-when-to-build-an-mcp-server-and-when-the-api-is-enough-1g08</link>
      <guid>https://dev.to/rss_holmes/mcp-vs-api-when-to-build-an-mcp-server-and-when-the-api-is-enough-1g08</guid>
      <description>&lt;p&gt;MCP vs API is rarely an either-or choice: MCP is a protocol that lets an AI agent discover and call tools at runtime, and an API is usually the service those tools wrap, so most teams building agents end up with both. The real decision is narrower. When does an agent need its own interface to your system, and when is the API you already have enough?&lt;/p&gt;

&lt;p&gt;We ran into this at my company when we wanted Claude and our internal agents to work with the same data our web and mobile apps use every day. We already had a REST API. We built an MCP server anyway. Ours runs on Google Cloud, and the reasons for building it were not the ones I expected. Let us walk through what MCP is, what it adds, what it costs, and a simple way to decide.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short answer: MCP vs API
&lt;/h2&gt;

&lt;p&gt;An API, usually a REST API, is a contract for software a developer writes. The developer reads the documentation, picks the endpoints and ships code that calls them in a known order. An MCP server is a contract for a model. The agent asks the server what it can do, reads the descriptions, and decides at runtime which tool to call and with what arguments.&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;API (usually REST)&lt;/th&gt;
&lt;th&gt;MCP server&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Who decides what to call&lt;/td&gt;
&lt;td&gt;A developer, at build time&lt;/td&gt;
&lt;td&gt;The model, at runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How capabilities are found&lt;/td&gt;
&lt;td&gt;Documentation or an OpenAPI file a person reads&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;tools/list&lt;/code&gt;: names, descriptions and input schemas returned by the server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transport&lt;/td&gt;
&lt;td&gt;HTTP&lt;/td&gt;
&lt;td&gt;JSON-RPC over stdio (local) or Streamable HTTP (remote)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth&lt;/td&gt;
&lt;td&gt;API keys, OAuth or service tokens&lt;/td&gt;
&lt;td&gt;Optional; OAuth 2.1 for HTTP servers, environment credentials for local ones&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;State&lt;/td&gt;
&lt;td&gt;Stateless per request&lt;/td&gt;
&lt;td&gt;Stateless per request in the current spec; each request carries its own version and capabilities&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Versioning&lt;/td&gt;
&lt;td&gt;URL or header versions you define&lt;/td&gt;
&lt;td&gt;The protocol is versioned by date; your tool names, schemas and descriptions are your contract&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Context cost&lt;/td&gt;
&lt;td&gt;None until your code puts data in a prompt&lt;/td&gt;
&lt;td&gt;Tool definitions occupy the model's context window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latency&lt;/td&gt;
&lt;td&gt;One network call&lt;/td&gt;
&lt;td&gt;Your API call, plus a hop to the MCP server, plus the model's decision&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clients&lt;/td&gt;
&lt;td&gt;Your own code&lt;/td&gt;
&lt;td&gt;Any MCP host: Claude, Claude Code, Cursor, VS Code and others&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Known workflows, high volume, integrations you control&lt;/td&gt;
&lt;td&gt;Open-ended requests where you cannot predict the sequence of calls&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the MCP server vs API question is really a question about who is calling. If the sequence of calls is known in advance, write code against the API. If people will ask an agent for things you cannot list in advance, and the agent has to reach your system to answer, that is when an MCP server starts to earn its keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  What MCP actually is, and what it isn't
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://modelcontextprotocol.io/specification/latest" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt; is an open protocol, currently specified in its 2026-07-28 revision, for connecting AI applications to tools and data. It uses JSON-RPC 2.0 messages between three roles: a host (the AI application, such as Claude Code), a client inside that host, and a server that offers capabilities.&lt;/p&gt;

&lt;p&gt;A server can offer three things: &lt;strong&gt;tools&lt;/strong&gt; the model can call, &lt;strong&gt;resources&lt;/strong&gt; that provide context, and &lt;strong&gt;prompts&lt;/strong&gt;, which are templates a user can invoke. Most servers start with tools, and so did we.&lt;/p&gt;

&lt;p&gt;There are two standard transports. &lt;strong&gt;stdio&lt;/strong&gt; runs the server as a local subprocess of the host, which suits developer tools on a laptop. &lt;strong&gt;Streamable HTTP&lt;/strong&gt; sends each message as an HTTP POST to a single endpoint, with the reply as a JSON object or a stream scoped to that request, which suits a shared, remote server.&lt;/p&gt;

&lt;p&gt;The current revision made the protocol stateless. Earlier revisions opened a connection-scoped session with an &lt;code&gt;initialize&lt;/code&gt; handshake, and many articles still describe that model, along with the older HTTP-plus-SSE transport. Today every request carries its protocol version and client capabilities, so a remote MCP server can sit behind an ordinary load balancer like any other HTTP service.&lt;/p&gt;

&lt;p&gt;What MCP is not: a replacement for your backend, or a source of new capability. Every tool does its work by calling something underneath, usually the same API your apps use. If the API cannot do it, the MCP server cannot either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can an API be an MCP server? Wrapping one in about 30 lines
&lt;/h2&gt;

&lt;p&gt;Yes. The most common MCP server is a thin, carefully described layer over an existing API, and wrapping one endpoint is the quickest way to learn how to create an MCP server. Here is the job of finding a customer's overdue invoices, first as a direct API call. The endpoint is illustrative, not ours.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;

&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.example.com/v1/invoices&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;C-1042&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unpaid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;overdue_days_gte&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;API_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;invoice&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount_due&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the same capability as an MCP tool, using the &lt;a href="https://github.com/modelcontextprotocol/python-sdk" rel="noopener noreferrer"&gt;official Python SDK&lt;/a&gt;. It calls the same endpoint. What changes is that a model can find it, read when to use it, and supply the arguments itself.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.server.mcpserver&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;MCPServer&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.types&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ToolAnnotations&lt;/span&gt;

&lt;span class="n"&gt;API_BASE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;API_BASE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.example.com/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;mcp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MCPServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoices&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;annotations&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;ToolAnnotations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;read_only_hint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_overdue_invoices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;days_overdue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;List a customer&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s unpaid invoices that are more than `days_overdue` days past due.

    Use this when asked who owes money or which invoices to chase.
    Returns an empty list when nothing is overdue; that is an answer, not an error.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;API_BASE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;API_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/invoices&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unpaid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;overdue_days_gte&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;days_overdue&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transport&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;streamable-http&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# serves http://127.0.0.1:8000/mcp
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran this with version 2.2.0 of the SDK against a small local stand-in for the invoice API. A client connected over Streamable HTTP, listed the tool with its description, input schema and read-only hint, and the call returned the stand-in's invoice. If you are on version 1 of the SDK, the same class is called &lt;code&gt;FastMCP&lt;/code&gt; and lives in &lt;code&gt;mcp.server.fastmcp&lt;/code&gt;; version 2 renamed it.&lt;/p&gt;

&lt;p&gt;To use it from Claude Code, register the endpoint (the syntax is in &lt;a href="https://code.claude.com/docs/en/mcp" rel="noopener noreferrer"&gt;Claude Code's MCP documentation&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add &lt;span class="nt"&gt;--transport&lt;/span&gt; http invoices http://127.0.0.1:8000/mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details are worth copying. The docstring is the description the model reads, so it says when to use the tool and what an empty result means. The read-only annotation tells hosts the tool changes nothing, although a host should only trust that from a server it trusts. And the tool is shaped around a question someone asks, "which invoices are overdue", rather than around an endpoint with a dozen filters.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use MCP vs API
&lt;/h2&gt;

&lt;p&gt;Work down the list and stop at the first yes.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is the sequence of calls known in advance?&lt;/strong&gt; Call the API from code. A nightly job that pulls orders, asks a model to classify them and writes the labels back is a script; the model sits inside your workflow and has nothing to discover.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is there exactly one agent, in one framework, using a handful of tools?&lt;/strong&gt; Define the tools in that framework's function calling. Move them into an MCP server when a second consumer appears.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do you need high volume or tight latency?&lt;/strong&gt; Keep the model out of the request path and call the API directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Will people ask open-ended questions that need live data from your system, from more than one AI host?&lt;/strong&gt; Build an MCP server on top of your API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does the agent need to change data?&lt;/strong&gt; Build the server with read tools first, then add narrowly scoped write tools that ask for confirmation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does the agent need a method rather than access, such as how your team writes a release note?&lt;/strong&gt; That is not an MCP problem. It is a skill, and &lt;a href="https://nulltensor.com/posts/claude-skills-might-be-bigger-than/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=claude-skills-might-be-bigger-than" rel="noopener noreferrer"&gt;Claude Skills vs MCP&lt;/a&gt; covers where that line sits.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For us, question four decided it. Our one MCP server serves an engineer asking Claude Code about a customer's configuration and a colleague asking Claude a business question. Building a separate wrapper for each host would have meant maintaining the same integration several times.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we changed after putting an MCP server in front of our APIs
&lt;/h2&gt;

&lt;p&gt;The first version of our MCP server mapped endpoints to tools one to one, which exposed around 150 functions to the model. It worked, and the agent was poor at using it. With that many fine-grained tools, it chained the wrong ones together and spent context on definitions it never needed.&lt;/p&gt;

&lt;p&gt;What worked better:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fewer, task-shaped tools.&lt;/strong&gt; One tool that answers "what is the status of this order" can call three endpoints underneath. The model makes one decision instead of three.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read before write.&lt;/strong&gt; We started with read-only tools and added writes one at a time, each narrowly scoped and confirmed by the user in the host. The server has write tools today, and each has a well-defined input and output schema, so what goes in and what comes back is fixed rather than left to the model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The user's permissions, not the server's.&lt;/strong&gt; A call made for a person should see only what that person could see in the app. The specification forbids an MCP server from simply passing the client's token through to your API, so the server has to work out what the user is allowed to do itself. Ours calls an IAM endpoint that returns which functions the signed-in user has permission to use, and works within that list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Descriptions under review.&lt;/strong&gt; A change to a tool description goes through review like an API change, because it changes behaviour.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  MCP vs function calling vs CLI
&lt;/h2&gt;

&lt;p&gt;These three get compared constantly, and they sit at different layers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Function calling&lt;/strong&gt; is the model-side mechanism. You send tool definitions in an API request, the model replies asking to call one, your code runs it and returns the result. Every MCP host uses function calling underneath. MCP standardises where the definitions come from, so they arrive from a server at runtime instead of being written into each application.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A CLI&lt;/strong&gt; is the other practical option for coding agents. Claude Code can run shell commands, so a well-built command-line tool over your API, like &lt;code&gt;gh&lt;/code&gt; for GitHub, is often enough for developer workflows. It needs no new server, and its help text doubles as documentation. It also needs a shell, which chat applications do not give the model, and every user needs the tool installed and authenticated on their own machine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MCP&lt;/strong&gt; is the right layer when the same capability has to work across several hosts, including ones without a shell, with per-user authorization and descriptions written for a model. If you are choosing between MCP and skills rather than these three, the &lt;a href="https://nulltensor.com/posts/claude-skills-might-be-bigger-than/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=claude-skills-might-be-bigger-than" rel="noopener noreferrer"&gt;Skills vs MCP comparison&lt;/a&gt; covers that decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost and latency: what MCP adds
&lt;/h2&gt;

&lt;p&gt;MCP adds two costs, and neither is large for a well-designed server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Context.&lt;/strong&gt; Tool definitions sit in the model's context window. Every tool's name, description and schema is text the model reads before it does anything useful. A server with a handful of precise tools costs little. A server that exposes every endpoint as a tool can take a meaningful share of the window before the user has asked a question. Hosts are starting to mitigate this; Claude Code, for example, can search for tools and load definitions on demand instead of all at once. Keeping the tool list short remains the most reliable fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Latency.&lt;/strong&gt; Is MCP faster than an API? No. A tool call is your API call plus a hop to the MCP server, and in an agent it also waits for the model to decide which tool to call. That is the right trade for a person asking a question in plain language and the wrong one for a service handling thousands of requests a minute. I have not published timings here, because they depend far more on the model and the API behind the tool than on the protocol.&lt;/p&gt;

&lt;h2&gt;
  
  
  MCP vs API security
&lt;/h2&gt;

&lt;p&gt;An API exposes operations to code you control. An MCP server exposes them to a model that decides what to call, based partly on text it did not write. That changes the threat model.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prompt injection through tool results.&lt;/strong&gt; A record, email or ticket can contain instructions, and the model may follow them. Treat every tool result as untrusted input, and never let tool output alone authorise a write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool poisoning and over-broad descriptions.&lt;/strong&gt; A description tells the model when to use a tool, so a malicious or careless one can steer it. The specification tells hosts to treat descriptions and annotations as untrusted unless they come from a trusted server. Connect only servers you trust, and write your own descriptions narrowly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Least privilege.&lt;/strong&gt; Prefer separate read and write tools over one tool with a mode flag. On HTTP servers, the specification's OAuth flow lets a server ask for the smallest scope an operation needs and request more only when a call requires it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tokens bound to the server.&lt;/strong&gt; Tokens must be issued for the specific MCP server, and the server must not pass them through to other services. This closes the "confused deputy" route where a server acts with more authority than the user has.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consent and audit.&lt;/strong&gt; The specification requires hosts to get user consent before invoking a tool. Keep a human confirmation step for anything irreversible or customer-facing, and log every call with the user, arguments and result.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Maintenance belongs here too. An MCP server is another service to deploy, monitor and version, and changing a description can change agent behaviour as much as changing code. Test it the same way: keep a small set of realistic requests and check the agent still picks the right tools. It is the same idea as &lt;a href="https://nulltensor.com/posts/six-feedback-loops-ai-first-engineering-pipeline/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=six-feedback-loops-ai-first-engineering-pipeline" rel="noopener noreferrer"&gt;loop engineering&lt;/a&gt;, applied to your tool layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Will MCP replace APIs?
&lt;/h2&gt;

&lt;p&gt;No. An MCP server normally calls your existing APIs, and your apps, partners and scripts keep using those APIs directly. MCP adds a second front door, designed for models, on top of the same backend. The APIs that matter more in an agent world are the ones with clean, well-scoped operations, because those are the easiest to turn into good tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  MCP vs API: common questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is MCP just an API?&lt;/strong&gt; In a sense: it is a protocol, built on JSON-RPC, that a client uses to call a server. The difference is the audience. MCP standardises how a model discovers and calls tools, so one server works across many hosts without custom wrappers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When should I use MCP instead of an API?&lt;/strong&gt; When the caller is a model rather than your own code, the requests are open-ended, and more than one AI host needs the same capability. For fixed workflows and high-volume integrations, call the API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is MCP faster than an API?&lt;/strong&gt; No. A tool call is still your API call, plus a hop to the MCP server and, in an agent, the model's decision about which tool to use. For a fixed call made from code, the API is always faster. MCP's value is discovery and reuse across hosts, not speed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need an MCP server to use Claude with my API?&lt;/strong&gt; No. You can pass tool definitions to Claude directly through function calling, which is often the right place to start. An MCP server becomes worthwhile when several hosts or teams need the same tools.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I turn an API into an MCP server?&lt;/strong&gt; Pick the two or three questions people will actually ask, write one tool for each that calls your API underneath, describe each tool as precisely as you would brief a new colleague, and run it with the official SDK as in the example above. Start read-only. If you are new to extending Claude, &lt;a href="https://nulltensor.com/posts/getting-started-with-claude-skills/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=getting-started-with-claude-skills" rel="noopener noreferrer"&gt;how to create Claude Skills&lt;/a&gt; covers the lighter-weight option first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is MCP secure?&lt;/strong&gt; It can be, but the security is in your design rather than the protocol: narrow tools, least-privilege scopes, tokens bound to the server, untrusted tool output and a human in the loop for writes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves us
&lt;/h2&gt;

&lt;p&gt;MCP vs API is a question about who is on the other end. When your own code is, the API is enough and simpler. When a model is, deciding at runtime what to do for someone who asked in plain language, an MCP server gives it a way to find your tools, understand them and use them within that person's permissions. For us, the server was not a replacement for the API but a second, carefully designed front door on top of it. The work that mattered most was deciding which few tools deserved to exist, and writing their descriptions as carefully as the code.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://nulltensor.com/posts/mcp-vs-api/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=mcp-vs-api" rel="noopener noreferrer"&gt;nulltensor.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>api</category>
      <category>claude</category>
    </item>
    <item>
      <title>How I Halved My Python Backend’s Memory Usage</title>
      <dc:creator>Rohan Sen Sharma</dc:creator>
      <pubDate>Wed, 07 Oct 2026 03:00:00 +0000</pubDate>
      <link>https://dev.to/rss_holmes/how-i-halved-my-python-backends-memory-usage-57o5</link>
      <guid>https://dev.to/rss_holmes/how-i-halved-my-python-backends-memory-usage-57o5</guid>
      <description>&lt;p&gt;We’ve all been there. Your monitoring dashboard turns red. Memory is spiking. The immediate temptation is to double the instance size—bump that t3.medium to a t3.large and move on. But today, I want to show you why that is the lazy way out.&lt;/p&gt;

&lt;p&gt;We have a standard startup stack: &lt;strong&gt;FastAPI&lt;/strong&gt; for the backend and &lt;strong&gt;Celery&lt;/strong&gt; for background tasks, all running on a cost-effective EC2 instance with 4GB of RAM. The vital metrics for the server looked bizarre: Memory was screaming at &lt;strong&gt;75% utilization&lt;/strong&gt;, yet the CPU was barely waking up, sitting idle at &lt;strong&gt;2-3%&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This specific signature—High RAM, Low CPU—is the hallmark of an architecture that is &lt;strong&gt;over-provisioned for concurrency but under-optimized for memory.&lt;/strong&gt; Let me show you the three specific knobs I turned to cut our memory footprint in half, without spending a single extra cent.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lesson 1: The Math of Processes vs. Threads
&lt;/h2&gt;

&lt;p&gt;First, let’s look at the “Before” snapshot of the system. We were running:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;API:&lt;/strong&gt; 4 Gunicorn workers&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Celery:&lt;/strong&gt; 8 workers across 3 queues (2 normal queues and a priority queue)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Total:&lt;/strong&gt; 12 Python processes.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now, here is the expensive part. Each Python worker process consumes about 250MB of memory on average.&lt;/p&gt;

&lt;p&gt;On a 4GB instance, that leaves almost no room for the OS. We needed to consolidate.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Solution: Threads over Processes
&lt;/h3&gt;

&lt;p&gt;In Python, a &lt;strong&gt;Process&lt;/strong&gt; has its own memory space (it loads the whole interpreter, imports, and app code). A &lt;strong&gt;Thread&lt;/strong&gt; shares memory with other threads in the same process.&lt;/p&gt;

&lt;p&gt;I switched our FastAPI configuration from &lt;code&gt;workers=2&lt;/code&gt; (processes) to &lt;code&gt;workers=1&lt;/code&gt; with &lt;code&gt;threads=4&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Why does this work?&lt;/p&gt;

&lt;p&gt;Since our app is I/O bound (waiting on Database queries, BigQuery, and OpenAI calls), we don’t need multiple CPU cores fighting for the GIL (Global Interpreter Lock). Threads can handle concurrent requests efficiently because they release the lock while waiting for I/O.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Old Config (Processes):&lt;/strong&gt; ~500MB usage for equivalent concurrency.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;New Config (Threads):&lt;/strong&gt; ~250MB usage. &lt;strong&gt;We just saved 50% memory on the API layer&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Lesson 2: The “Leaking Bucket” Theory (Handling Memory Leaks)
&lt;/h2&gt;

&lt;p&gt;Even if your code is perfect, Python processes (especially those using libraries like Pandas or Numpy) tend to hold onto memory over time. It’s called memory fragmentation, or sometimes, just a plain old leak.&lt;/p&gt;

&lt;p&gt;If you process a large Dataframe in a Celery task, that memory might not be fully released back to the OS immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Fix&lt;/strong&gt; : tuning the &lt;code&gt;--max-tasks-per-child&lt;/code&gt; parameter&lt;/p&gt;

&lt;p&gt;This setting tells the Celery worker: &lt;em&gt;“After you finish X number of tasks, kill yourself and let a fresh process take your place.”&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We experimented a bit with this parameter and finally settled on a value of &lt;strong&gt;100&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Here is a Trade-off Matrix for the different values:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Too Low (e.g., 10):&lt;/strong&gt; Great for memory, but you waste CPU restarting workers constantly (~15% overhead).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Too High (e.g., 1000):&lt;/strong&gt; Low overhead, but memory leaks accumulate.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The Sweet Spot (100):&lt;/strong&gt; With our autoscaling enabled, workers naturally shut down when idle. This setting is just a safety net to ensure no single worker stays alive long enough to become bloated.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Lesson 3: The “Thundering Herd” (Gunicorn Jitter)
&lt;/h2&gt;

&lt;p&gt;Just like Celery, we want our Gunicorn (API) workers to restart occasionally to clear out memory “gunk.” We set &lt;code&gt;--max-requests 1000&lt;/code&gt; to restart the worker after 1000 HTTP requests.&lt;/p&gt;

&lt;p&gt;But there is a danger here.&lt;/p&gt;

&lt;p&gt;Imagine you have 2 workers. If they both start at the same time, and both hit their 1000th request at the same time, they will both restart at the same time.&lt;/p&gt;

&lt;p&gt;Result: Your API goes down for 2 seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Fix&lt;/strong&gt;: tuning the &lt;code&gt;--max-requests-jitter&lt;/code&gt; parameter&lt;/p&gt;

&lt;p&gt;We added &lt;code&gt;--max-requests-jitter 50&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This adds randomness. One worker might restart at 980 requests, the other at 1040. This staggering ensures that while one worker is recycling, the other is still serving traffic.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Final Report Card
&lt;/h2&gt;

&lt;p&gt;By understanding the difference between &lt;a href="https://nulltensor.com/posts/the-day-my-index-optimization-melted/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=the-day-my-index-optimization-melted" rel="noopener noreferrer"&gt;I/O bound and CPU bound tasks&lt;/a&gt;, and acknowledging that Python processes are memory-heavy, we drastically changed our footprint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;12 Total Workers&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;~3GB Memory Usage (75% Load)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;6 Total Workers (API threads + reduced Celery concurrency)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;~1.5GB Memory Usage (40% Load)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the next time &lt;a href="https://nulltensor.com/posts/using-ai-to-investigate-an-unfamiliar-error-or-stack-trace/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=using-ai-to-investigate-an-unfamiliar-error-or-stack-trace" rel="noopener noreferrer"&gt;your server metrics are turning red&lt;/a&gt;, before vertically scaling your servers try tuning your app and worker configurations. Go check your &lt;code&gt;supervisor.conf&lt;/code&gt; or &lt;code&gt;Dockerfile&lt;/code&gt; and answer these questions -&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Are you running multiple Gunicorn workers for an I/O bound app? Try threads.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Do your Celery workers live forever? Add &lt;code&gt;max-tasks-per-child&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Are you monitoring RSS memory? &lt;code&gt;watch -n 5 ‘ps aux | grep celery’&lt;/code&gt; is your friend.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;STOP THROWING MONEY at your cloud provider.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://nulltensor.com/posts/how-i-halved-my-backend-python-apps/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=how-i-halved-my-backend-python-apps" rel="noopener noreferrer"&gt;nulltensor.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>fastapi</category>
      <category>performance</category>
      <category>devops</category>
    </item>
    <item>
      <title>The Day My "Index Optimization" Melted Our Database 🔥</title>
      <dc:creator>Rohan Sen Sharma</dc:creator>
      <pubDate>Tue, 06 Oct 2026 03:00:00 +0000</pubDate>
      <link>https://dev.to/rss_holmes/the-day-my-index-optimization-melted-our-database-2551</link>
      <guid>https://dev.to/rss_holmes/the-day-my-index-optimization-melted-our-database-2551</guid>
      <description>&lt;p&gt;It was a regular Monday morning. I was sipping my chai ☕, scanning through our monitoring dashboards, when I noticed something that had been bugging me for weeks - a SQL condition that was obviously wrong.&lt;/p&gt;

&lt;p&gt;Many queries in our multi-tenant SaaS application had this filter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;org_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;is_active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;is_deleted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That “&lt;code&gt;OR tenant_id IS NULL”&lt;/code&gt; was sitting in &lt;strong&gt;every single query&lt;/strong&gt; for a lot of tables. And I knew from years of database experience that &lt;code&gt;OR&lt;/code&gt; conditions are index killers. Every DBA will tell you:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“An OR with IS NULL prevents MySQL from using indexes efficiently.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So I did what any reasonable engineer would do. I removed it.&lt;/p&gt;

&lt;p&gt;I pushed the fix. Deployed it. And then watched our RDS CPU climb from a comfortable 60% to a screaming &lt;strong&gt;100% 📈💀&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I had just optimized our database into a meltdown 🔥.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0xkvzkfme0wa70tw0a31.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0xkvzkfme0wa70tw0a31.webp" alt="Illustration of a database outage with a monitoring dashboard and servers on fire" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  🏗️ The Setup: A Multi-Tenant Nightmare
&lt;/h2&gt;

&lt;p&gt;Let me give you some context. We run a B2B SaaS platform - think of it like a simplified ERP system. Every customer (tenant) has their own data, but it all lives in the same database. The data model looks something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="n"&gt;AUTO_INCREMENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;org_id&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;order_date&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="nb"&gt;DECIMAL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;is_active&lt;/span&gt; &lt;span class="nb"&gt;TINYINT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;is_deleted&lt;/span&gt; &lt;span class="nb"&gt;TINYINT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;-- ... 30 more columns&lt;/span&gt;
    &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;is_active&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 &lt;strong&gt;orders&lt;/strong&gt; table had ~200,000 rows. Not huge, but not trivial either. And here’s the kicker, for any given tenant, a query would typically return about &lt;strong&gt;50,000 rows&lt;/strong&gt; out of those 200,000. That’s a 25% selectivity ratio. Remember this number. It’s going to matter a lot.&lt;/p&gt;

&lt;p&gt;Now, some tables in our system had “shared” or “global” records, things like default payment terms, standard tax rates, common bank entries. These records had &lt;code&gt;tenant_id = NULL&lt;/code&gt; because they belonged to everyone. But somewhere along the way, a developer had added the &lt;code&gt;OR tenant_id IS NULL&lt;/code&gt; condition to &lt;em&gt;every&lt;/em&gt; model’s query, not just the ones with shared records.&lt;/p&gt;

&lt;p&gt;It was sloppy. It was clearly wrong. And it was my job to clean it up.&lt;/p&gt;




&lt;h2&gt;
  
  
  💥 The “Fix” That Broke Everything
&lt;/h2&gt;

&lt;p&gt;The change was surgical. I went through the codebase and updated the query builder:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before (every model):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;org_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;is_active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;is_deleted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After (transactional models only):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;org_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;is_active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;is_deleted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clean. Precise. The kind of change that should make indexes happy.&lt;/p&gt;

&lt;p&gt;I deployed it on a Tuesday morning. By Tuesday afternoon, our Slack was on fire 🔥.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“The dashboard is crawling.”&lt;/p&gt;

&lt;p&gt;“API timeouts across the board.”&lt;/p&gt;

&lt;p&gt;“RDS CPU is pegged at 100%.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I checked New Relic. Query times were actually &lt;em&gt;slightly faster&lt;/em&gt; . 1-3 seconds vs the previous 2-5 seconds. But CPU had gone through the roof. How could faster queries use &lt;em&gt;more&lt;/em&gt; CPU?&lt;/p&gt;

&lt;p&gt;I reverted the change. CPU dropped back to ~60%. Queries went back to being slow but stable.&lt;/p&gt;

&lt;p&gt;I sat there staring at my screen, feeling like I’d just witnessed a violation of the laws of physics 🤯.&lt;/p&gt;




&lt;h2&gt;
  
  
  🕳️ Down the Rabbit Hole: InnoDB’s Dirty Secret
&lt;/h2&gt;

&lt;p&gt;Here’s where things get interesting. To understand what happened, you need to understand something most developers never think about: &lt;strong&gt;how InnoDB actually stores your data&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;InnoDB doesn’t just dump rows into a file. It maintains &lt;strong&gt;two separate data structures&lt;/strong&gt; for every table:&lt;/p&gt;

&lt;h3&gt;
  
  
  Structure 1: The Clustered Index (a.k.a. “The Actual Table”)
&lt;/h3&gt;

&lt;p&gt;In InnoDB, the primary key isn’t just an index - it &lt;em&gt;is&lt;/em&gt; the table. Rows are physically stored sorted by the primary key. When you do a full table scan, you’re reading this structure sequentially, cover to cover, like reading a book.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fimztfv792jnjfmqpzbg5.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fimztfv792jnjfmqpzbg5.webp" alt="Clustered primary-key index storing full rows in primary-key order" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Structure 2: Secondary Indexes (Your Regular Indexes)
&lt;/h3&gt;

&lt;p&gt;Every other index like &lt;code&gt;INDEX(tenant_id)&lt;/code&gt;, &lt;code&gt;INDEX(org_id)&lt;/code&gt; is a &lt;em&gt;secondary index&lt;/em&gt;. And here’s the crucial part: &lt;strong&gt;secondary indexes don’t contain the full row data&lt;/strong&gt;. They only contain:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;The indexed column(s)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A pointer back to the primary key&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That’s it. No &lt;code&gt;org_id&lt;/code&gt;, &lt;code&gt;is_active&lt;/code&gt; or &lt;code&gt;amount&lt;/code&gt;. Just the indexed column and a PK reference.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7aab1lyn4w5r4brzaemq.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7aab1lyn4w5r4brzaemq.webp" alt="Secondary tenant index mapping tenant IDs to primary keys for row lookups" width="800" height="966"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This design choice has a profound consequence.&lt;/p&gt;




&lt;h2&gt;
  
  
  💸 The Two-Lookup Tax
&lt;/h2&gt;

&lt;p&gt;When MySQL uses a secondary index to find rows, it has to do &lt;strong&gt;two lookups for every single match&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Look up the secondary index&lt;/strong&gt; → finds matching PKs&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Look up the clustered index&lt;/strong&gt; → retrieves the actual row data using each PK&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This second step is called a “bookmark lookup” or “clustered index lookup.” And it’s not a sequential read- it’s a &lt;strong&gt;random seek&lt;/strong&gt;. The primary keys from the secondary index are scattered across the clustered index. So for each match, InnoDB has to jump to a completely different location on disk.&lt;/p&gt;

&lt;p&gt;Let me paint this picture clearly.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Happened BEFORE My Change (Full Table Scan)
&lt;/h3&gt;

&lt;p&gt;With “&lt;code&gt;OR tenant_id IS NULL”&lt;/code&gt; in the query, MySQL’s optimizer looked at the query and thought: &lt;em&gt;“This OR condition makes the index useless. I’ll just scan the whole table.”&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full Table Scan:&lt;/strong&gt;\&lt;br&gt;
Read row 1 → check conditions → keep/discard\&lt;br&gt;
Read row 2 → check conditions → keep/discard\&lt;br&gt;
Read row 3 → check conditions → keep/discard\&lt;br&gt;
... (200,000 rows, read sequentially)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I/O Pattern:&lt;/strong&gt; Sequential(Like reading a book from start to finish)\&lt;br&gt;
&lt;strong&gt;CPU cost:&lt;/strong&gt; LOW (just comparing values)\&lt;br&gt;
&lt;strong&gt;Disk cost:&lt;/strong&gt; HIGH but efficient (sequential reads)\&lt;br&gt;
&lt;strong&gt;Wall time:&lt;/strong&gt; 2-5 seconds&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sequential I/O&lt;/strong&gt; is remarkably efficient. The disk head moves in one direction, reading contiguous blocks. The CPU barely breaks a sweat it’s just checking &lt;code&gt;if (tenant_id == 42)&lt;/code&gt; for each row. Most of the time, the CPU is &lt;em&gt;waiting&lt;/em&gt; for the disk to deliver the next chunk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is an I/O-bound operation.&lt;/strong&gt; The bottleneck is disk speed, not CPU.&lt;/p&gt;
&lt;h3&gt;
  
  
  What Happened AFTER My Change (Index Scan)
&lt;/h3&gt;

&lt;p&gt;Without the OR condition, MySQL suddenly had a usable index on &lt;code&gt;tenant_id&lt;/code&gt;. So the optimizer switched strategies: &lt;em&gt;“Great, I can use the tenant_id index! Let me find all rows where tenant_id = 42.”&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Index Scan + Clustered Index Lookups:&lt;/strong&gt;\&lt;br&gt;
Step 1: Scan tenant_id index → find 50,000 matching PKs\&lt;br&gt;
Step 2: For EACH of those 50,000 PKs:\&lt;br&gt;
→ Random jump to clustered index\&lt;br&gt;
→ Read full row\&lt;br&gt;
→ Check: org_id = 100? is_active = 1? is_deleted = 0?\&lt;br&gt;
→ Keep or discard&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I/O Pattern:&lt;/strong&gt; Random (Like flipping to 50,000 random pages in a book)\&lt;br&gt;
&lt;strong&gt;CPU cost:&lt;/strong&gt; HIGH (coordinating 50,000 random seeks)\&lt;br&gt;
&lt;strong&gt;Disk cost:&lt;/strong&gt; 50,000 individual random reads\&lt;br&gt;
&lt;strong&gt;Wall time:&lt;/strong&gt; 1-3 seconds&lt;/p&gt;

&lt;p&gt;Each of those 50,000 random seeks requires the CPU to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Navigate the B-tree index structure&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Calculate the physical disk location&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Issue the I/O request&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Handle the I/O completion interrupt&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Manage the InnoDB buffer pool&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;This is a CPU-bound operation.&lt;/strong&gt; The CPU is constantly busy coordinating random I/O.&lt;/p&gt;


&lt;h2&gt;
  
  
  🧮 The Counter-Intuitive Math
&lt;/h2&gt;

&lt;p&gt;Here’s where it clicks. Let me lay out the numbers:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb05al5siwdwezibv57qr.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb05al5siwdwezibv57qr.webp" alt="Comparison of sequential table scans and single-column index scans: fewer rows can still require more CPU and random I/O" width="799" height="276"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The index scan was &lt;em&gt;faster&lt;/em&gt; in wall-clock time therefore the query returned results quicker. But it consumed &lt;strong&gt;5x more CPU&lt;/strong&gt; doing it. Under concurrent load (dozens of these queries running simultaneously), the CPU saturated, everything started queuing, and the whole system ground to a halt.&lt;/p&gt;


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

&lt;p&gt;This leads to one of the most important rules in database performance that many engineers miss:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Selectivity&lt;/strong&gt; = (Rows returned) / (Total rows in table)&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fknou4pbeh4h3yy7uslw6.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fknou4pbeh4h3yy7uslw6.webp" alt="Illustrative selectivity spectrum showing when an index or table scan may be preferable" width="800" height="383"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&amp;lt; 5% selectivity&lt;/strong&gt;: Index scans are clearly better. Few lookups = low overhead.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;5-15% selectivity&lt;/strong&gt;: Grey zone. Depends on hardware, data distribution, index type.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&amp;gt; 15% selectivity&lt;/strong&gt;: Full table scans often beat single-column index scans.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Our queries had &lt;strong&gt;25% selectivity&lt;/strong&gt;. We were firmly in “table scan wins” territory for single-column indexes. MySQL’s optimizer actually &lt;em&gt;knew&lt;/em&gt; this and that’s why it chose table scans when the OR condition was present. We accidentally forced it into an inferior plan by making the index “usable.”&lt;/p&gt;


&lt;h2&gt;
  
  
  ✅ The Real Fix: Composite Indexes
&lt;/h2&gt;

&lt;p&gt;So if single-column indexes are the problem, what’s the solution? You don’t go back to table scans, instead you use &lt;strong&gt;composite indexes&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A composite index includes multiple columns in a single index structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_orders_tenant_org_active_deleted&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_active&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_deleted&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here’s what this changes. Instead of a secondary index that only knows about &lt;code&gt;tenant_id&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Single-column index:&lt;/strong&gt;\&lt;br&gt;
Find tenant_id = 42 → 50,000 rows\&lt;br&gt;
&lt;strong&gt;Then:&lt;/strong&gt; 50,000 random PK lookups to check other conditions&lt;/p&gt;

&lt;p&gt;The composite index knows about &lt;em&gt;all four filter columns&lt;/em&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Composite index on (tenant_id, org_id, is_active, is_deleted):&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Navigate to tenant_id = 42&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;└─ Navigate to org_id = 100&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;└─ Navigate to is_active = 1&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;└─ Navigate to is_deleted = 0&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;└─ Only 1,000 rows match ALL conditions!&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then:&lt;/strong&gt; Only 1,000 PK lookups (not 50,000!)&lt;/p&gt;

&lt;p&gt;The filtering happens &lt;em&gt;inside&lt;/em&gt; the index itself. By the time MySQL needs to do PK lookups to fetch full row data, it’s already narrowed down from 50,000 to 1,000 rows. That’s a &lt;strong&gt;50x reduction&lt;/strong&gt; in random I/O.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F533m9gxd5n8ujmcnvuzd.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F533m9gxd5n8ujmcnvuzd.webp" alt="Comparison showing how a composite index reduces row lookups and query cost" width="799" height="276"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  🐘 Wait, But Would This Happen in PostgreSQL?
&lt;/h2&gt;

&lt;p&gt;This is where things get engine-specific. Everything I’ve described is a characteristic of &lt;strong&gt;InnoDB’s clustered index architecture&lt;/strong&gt;. PostgreSQL handles things differently, and the same scenario would play out differently there.&lt;/p&gt;

&lt;h3&gt;
  
  
  InnoDB (MySQL): Clustered Index Architecture
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;The table &lt;em&gt;is&lt;/em&gt; the primary key index (clustered)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Secondary indexes store PK pointers, not row data&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Every secondary index lookup requires a second hop to the clustered index&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;This “double lookup” is the root cause of our CPU spike&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  PostgreSQL: Heap-Based Architecture
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;The table is stored as a &lt;strong&gt;heap&lt;/strong&gt; where rows are stored in insertion order, not sorted by PK&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;em&gt;All&lt;/em&gt; indexes (including the primary key) are secondary indexes&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Indexes point to a physical row location (tuple ID / ctid) on the heap&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;There’s no “clustered index lookup” step&lt;/strong&gt; because the index points directly to the row’s physical location&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;InnoDB :&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Secondary Index → PK value → Clustered Index → Row Data&lt;/p&gt;

&lt;p&gt;(Two B-tree traversals per row)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PostgreSQL:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Index → Physical Row Location (ctid) → Row Data&lt;/p&gt;

&lt;p&gt;(One B-tree traversal + one heap fetch per row)&lt;/p&gt;

&lt;p&gt;So in PostgreSQL, our 50,000 index matches would each require &lt;strong&gt;one&lt;/strong&gt; hop to the heap, not two hops through the clustered index. The CPU overhead would be lower.&lt;/p&gt;

&lt;p&gt;But PostgreSQL has its own trade-off. Since the heap isn’t sorted by any index, &lt;em&gt;all&lt;/em&gt; index lookups involve random heap access. There’s no concept of a “clustered scan” where the index order matches the physical data order (unless you explicitly use &lt;code&gt;CLUSTER&lt;/code&gt;, which is a one-time operation that doesn’t persist).&lt;/p&gt;

&lt;p&gt;In InnoDB, if your query scans the primary key in order, you get beautiful sequential reads because the data &lt;em&gt;is&lt;/em&gt; physically sorted by PK. In PostgreSQL, even a primary key scan can involve random heap access.&lt;/p&gt;

&lt;p&gt;For a different PostgreSQL failure mode, see &lt;a href="https://nulltensor.com/posts/using-ai-to-investigate-an-unfamiliar-error-or-stack-trace/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=using-ai-to-investigate-an-unfamiliar-error-or-stack-trace" rel="noopener noreferrer"&gt;how we used AI to investigate a search query that bypassed its trigram index&lt;/a&gt;. That investigation connects Django's generated SQL, the index definition and the query plan, then checks the proposed fix against both timings and search results.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧠 Key Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;“OR kills indexes” is true but that might be saving you.&lt;/strong&gt; If your single-column indexes would cause worse performance than a table scan, the OR condition is accidentally keeping you in the better execution plan.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Selectivity is everything.&lt;/strong&gt; If your query returns more than 15% of a table’s rows, a single-column index scan can be &lt;em&gt;worse&lt;/em&gt; than a full table scan due to random I/O overhead.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Understand your storage engine.&lt;/strong&gt; InnoDB’s clustered index architecture means every secondary index lookup pays a “double-hop” tax. This tax is invisible when selectivity is low but devastating when selectivity is high.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Composite indexes are the real answer.&lt;/strong&gt; They don’t just make queries faster, they fundamentally change the math by filtering inside the index before touching the main table.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Monitor CPU, not just query latency.&lt;/strong&gt; A query that’s 2x faster but uses 5x more CPU is a net negative under concurrent load.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Know your engine’s differences.&lt;/strong&gt; The same schema optimization can have very different effects on InnoDB vs PostgreSQL due to their fundamentally different storage architectures.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;The next time someone tells you “just add an index,” ask them: “What kind of index, and what’s your query’s selectivity?” The answer might surprise you.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://nulltensor.com/posts/the-day-my-index-optimization-melted/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=the-day-my-index-optimization-melted" rel="noopener noreferrer"&gt;nulltensor.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mysql</category>
      <category>database</category>
      <category>performance</category>
      <category>sql</category>
    </item>
    <item>
      <title>If leaders want AI speed, they must share the production risk</title>
      <dc:creator>Rohan Sen Sharma</dc:creator>
      <pubDate>Mon, 05 Oct 2026 03:00:00 +0000</pubDate>
      <link>https://dev.to/rss_holmes/if-leaders-want-ai-speed-they-must-share-the-production-risk-2ib7</link>
      <guid>https://dev.to/rss_holmes/if-leaders-want-ai-speed-they-must-share-the-production-risk-2ib7</guid>
      <description>&lt;p&gt;A recent &lt;a href="https://x.com/v0xium/status/2101526107128529120?s=20" rel="noopener noreferrer"&gt;post on X&lt;/a&gt; from an engineer named &lt;a href="https://x.com/v0xium" rel="noopener noreferrer"&gt;v0xium&lt;/a&gt; took the social media by storm. He described an organisation where AI generated much of the specifications, code, tests, tickets, and reports surrounding software delivery. His deeper complaint was organisational. People were pushed to ship faster without enough time to understand the work, while engineers still carried responsibility when it failed.&lt;/p&gt;

&lt;p&gt;Being an engineering leader running a fast paced AI powered engineering team , this problem struck close to home. That concern is legitimate. But it also describes only one side of a difficult transition. AI is changing the economics of software production quickly, and leadership cannot wait for the industry to discover a perfect operating model. So today the useful question is how can a company take larger bets without making engineers the sole owners of the resulting risk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Both sides are responding to real responsibilities
&lt;/h2&gt;

&lt;p&gt;An engineer who reviews a change has to imagine what happens when it reaches production. &lt;a href="https://nulltensor.com/posts/ai-generated-test-coverage/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=ai-generated-test-coverage" rel="noopener noreferrer"&gt;Does the generated test check the business outcome?&lt;/a&gt; Can we roll the change back? Will an incident wake the same team at 2 a.m.? If the system fails, the organisation will expect engineers to diagnose it and restore service. Asking for stronger quality controls is a rational response to that accountability.&lt;/p&gt;

&lt;p&gt;Leadership has a different responsibility. A founder or CTO has to decide which changes could materially improve the business, how quickly the company must learn, and which risks are worth taking. Incremental improvements may keep an existing system healthy while a competitor discovers a much more efficient way to operate. Refusing every uncertain experiment can therefore be as consequential as shipping an unsafe one.&lt;/p&gt;

&lt;p&gt;The tension becomes sharper because AI increases the amount of work a team can start. DORA's &lt;a href="https://cloud.google.com/blog/products/ai-machine-learning/announcing-the-2025-dora-report" rel="noopener noreferrer"&gt;2025 research on AI-assisted software development&lt;/a&gt; reported a positive relationship between AI adoption and delivery throughput, alongside a negative relationship with delivery stability. The report's broader conclusion is useful here: AI amplifies the system of work around it. Faster generation exposes weak testing, unclear ownership, slow feedback, and fragile architecture sooner.&lt;/p&gt;

&lt;p&gt;Neither side can solve that by winning an argument about whether AI is good or bad. They need an agreement about how risk will be taken and who will carry it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Speed without shared risk creates defensive behaviour
&lt;/h2&gt;

&lt;p&gt;Suppose leadership asks a team to use an agent to change how invoices are matched to payments. The experiment could reduce manual work across finance and support. It could also attach a payment to the wrong invoice, create misleading account balances, or require a difficult correction later.&lt;/p&gt;

&lt;p&gt;If the instruction is simply to move faster, the incentives diverge. Management receives the potential business gain. Engineers inherit the production failure, investigation, and repair. The safest personal response for an engineer is to slow the change down or resist it entirely.&lt;/p&gt;

&lt;p&gt;The opposite arrangement also fails. If engineers can reject every experiment until uncertainty disappears, leadership remains accountable for growth without the ability to explore a new operating model. The company protects the current system at the cost of learning what could replace it.&lt;/p&gt;

&lt;p&gt;Shared accountability changes the conversation. Leadership still decides that the experiment is worth attempting. Engineering still defines what is technically safe enough to attempt. Both sides agree on the boundaries, observe the outcome, and own the consequences.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the experiment reversible before making it fast
&lt;/h2&gt;

&lt;p&gt;For the invoice-matching example, the first decision should not be whether the agent is accurate enough to run everywhere. The first decision should be how to learn without creating an uncontrolled failure.&lt;/p&gt;

&lt;p&gt;The team could &lt;a href="https://nulltensor.com/posts/support-ai-pilot-50-resolved-tickets/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=support-ai-pilot-50-resolved-tickets" rel="noopener noreferrer"&gt;begin with historical records&lt;/a&gt;, then run the agent in shadow mode on live work without allowing it to update an invoice. Once its errors are understood, a limited/canary rollout could permit changes for one low-risk class of transactions. A feature flag or workflow switch should provide a tested path back to the previous process. High-value or ambiguous matches could remain subject to human review.&lt;/p&gt;

&lt;p&gt;These controls do more than reduce technical risk. They make the leadership decision explicit. Everyone can see which failure modes the company accepted, which it refused, and what evidence is required before expanding the rollout.&lt;/p&gt;

&lt;p&gt;A useful operating agreement would answer several connected questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What customer or business outcome justifies the experiment?&lt;/li&gt;
&lt;li&gt;Which records, tenants, or workflows are inside its initial boundary?&lt;/li&gt;
&lt;li&gt;What must remain unchanged even if the agent makes a mistake?&lt;/li&gt;
&lt;li&gt;Which signals &lt;a href="https://nulltensor.com/posts/six-feedback-loops-ai-first-engineering-pipeline/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=six-feedback-loops-ai-first-engineering-pipeline" rel="noopener noreferrer"&gt;cause the rollout to pause or reverse&lt;/a&gt;?&lt;/li&gt;
&lt;li&gt;Who has the authority to stop it, and who owns the recovery?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The answers should be recorded before the rollout. A short decision record is enough if it captures the assumption, risk boundary, evidence, owner, and review date. The goal is not process for its own sake. It prevents the organisation from rewriting the original decision after seeing the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reliability has to be a leadership constraint
&lt;/h2&gt;

&lt;p&gt;Operational reliability cannot remain an engineering concern that leadership supports only after an incident. It has to constrain how the company takes bets.&lt;/p&gt;

&lt;p&gt;Google's example &lt;a href="https://sre.google/workbook/error-budget-policy/" rel="noopener noreferrer"&gt;error-budget policy&lt;/a&gt; shows one way to make this trade-off explicit. When a service remains within its reliability objective, releases proceed. When it exceeds the agreed budget, reliability work takes priority. The policy is not framed as punishment. It gives the organisation a shared rule for deciding when further change is acceptable.&lt;/p&gt;

&lt;p&gt;An AI-first team can apply the same principle without copying the policy literally. Leadership can ask for rapid experiments while accepting limits on blast radius, rollout pace, and accumulated operational risk. Engineers can support larger bets while accepting that zero risk is not the goal. The boundary is agreed in advance and adjusted using production evidence.&lt;/p&gt;

&lt;p&gt;Observability is part of that agreement. Technical metrics such as errors, latency, and failed jobs matter. The business outcome matters too. An agent that produces no exceptions while silently increasing invoice corrections has not succeeded. A dashboard should make both dimensions visible to the people who authorised the experiment.&lt;/p&gt;

&lt;p&gt;Incident ownership must follow decision authority as well. Engineers will lead diagnosis because they understand the system. Product and business leaders should remain present for customer impact, operational trade-offs, and follow-up priority. When leadership participates in the consequences, reliability becomes a company decision rather than a burden delegated to the on-call team.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trust requires permission to stop and permission to fail
&lt;/h2&gt;

&lt;p&gt;Transparency cannot mean announcing a decision after it has already been made. Engineers need enough context to understand why the company is taking the risk and enough authority to stop a rollout when the agreed boundary is crossed. Leaders need honest technical judgement, including uncertainty, rather than a demand for guarantees that software teams cannot provide.&lt;/p&gt;

&lt;p&gt;The same trust must run in the other direction. Founders and managers need room to test ideas that may fail. Engineers can help make those failures contained, observable, and informative instead of treating every unsuccessful experiment as evidence that it should never have been attempted.&lt;/p&gt;

&lt;p&gt;This transition will be messy because nobody has a settled answer for how AI changes software delivery. Every side will make poor calls. The way through is to expose assumptions, define reversible boundaries, share the operational consequences, and turn each result into the next decision.&lt;/p&gt;

&lt;p&gt;Engineers should not carry an AI strategy's production risk alone. Leadership should not carry the responsibility for transformation without room to experiment. A team can move quickly when both sides are willing to be transparent about what they do not know, vulnerable about mistakes, and accountable for what happens next.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://nulltensor.com/posts/ai-speed-shared-production-risk/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=ai-speed-shared-production-risk" rel="noopener noreferrer"&gt;nulltensor.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>leadership</category>
      <category>devops</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>A 90% agent PR merge rate can still describe a weak engineering workflow</title>
      <dc:creator>Rohan Sen Sharma</dc:creator>
      <pubDate>Sun, 04 Oct 2026 03:00:00 +0000</pubDate>
      <link>https://dev.to/rss_holmes/a-90-agent-pr-merge-rate-can-still-describe-a-weak-engineering-workflow-5858</link>
      <guid>https://dev.to/rss_holmes/a-90-agent-pr-merge-rate-can-still-describe-a-weak-engineering-workflow-5858</guid>
      <description>&lt;p&gt;When a coding agent opens ten pull requests and reviewers merge eight, an 80% acceptance rate looks like a useful performance measure. It tells us that most submitted changes cleared review. It does not tell us whether the agent worked on the problems that mattered, reduced delivery time, or left the product in a better state.&lt;/p&gt;

&lt;p&gt;The denominator contains only the work we gave the agent and allowed it to submit. Once teams begin optimising that ratio, they have an incentive to route small, well-specified changes to the agent and keep ambiguous, high-impact work elsewhere. The metric can improve while the agent contributes little to the team’s real constraint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Acceptance measures the submitted portfolio
&lt;/h2&gt;

&lt;p&gt;Pull-request acceptance rate is straightforward:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Accepted agent pull requests ÷ submitted agent pull requests&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Both parts are shaped before review begins. Someone selects the task, decides whether the agent’s attempt is presentable, and chooses whether to open a pull request at all. A failed attempt abandoned in a local branch may never reach the denominator.&lt;/p&gt;

&lt;p&gt;Task type changes the result too. A &lt;a href="https://arxiv.org/abs/2602.08915" rel="noopener noreferrer"&gt;2026 paper&lt;/a&gt; analysed 7,156 agent-generated pull requests from the AIDev dataset. Documentation changes had an 82.1% acceptance rate, while new features had 66.1%. Across all categories, the reported range ran from 84.0% for chores to 55.4% for performance work. The authors describe task type as a major factor and also note an alternative explanation: review standards may differ by category. This is observational evidence from public repositories, not proof that the same gap exists inside every company.&lt;/p&gt;

&lt;p&gt;Another &lt;a href="https://arxiv.org/abs/2509.14745" rel="noopener noreferrer"&gt;study&lt;/a&gt; examined 567 Claude Code pull requests across 157 open-source projects. It reported that developers tended to use the agent for refactoring, documentation and testing. Although 83.8% of the pull requests were merged, only 54.9% of those merged needed no further modification.&lt;/p&gt;

&lt;p&gt;These studies do not establish the business value of any individual change. They show why an unsegmented acceptance rate is hard to interpret: the mix of work and the human correction behind the merge both matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  A perfect score can come from a narrow assignment
&lt;/h2&gt;

&lt;p&gt;Consider a hypothetical B2B SaaS backlog with three changes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Correct an API example in the documentation.&lt;/li&gt;
&lt;li&gt;Add validation for a missing optional field.&lt;/li&gt;
&lt;li&gt;Change subscription cancellation across billing, access control, stored files and outbound webhooks.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first two tasks are bounded and easy to verify. The third crosses systems, contains unresolved product decisions and &lt;a href="https://nulltensor.com/posts/ai-speed-shared-production-risk/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=ai-speed-shared-production-risk" rel="noopener noreferrer"&gt;carries a larger rollback cost&lt;/a&gt;. Suppose an agent completes the first two, both pull requests are accepted, and the team handles the cancellation change manually.&lt;/p&gt;

&lt;p&gt;The agent’s acceptance rate is 100%. That is accurate. It is also incomplete.&lt;/p&gt;

&lt;p&gt;Sure, the agent removed two useful pieces of work, so we should not dismiss the contribution. But the problem appears when we use the score to answer a different question: how much valuable engineering work can this workflow take on? The metric has no representation of the unattempted third task, its relative impact, or the effort reviewers spent correcting the accepted changes for the 1st and 2nd scenarios.&lt;/p&gt;

&lt;p&gt;This is a broader measurement problem. In an &lt;a href="https://metr.org/notes/2026-02-17-exploratory-transcript-analysis-for-estimating-time-savings-from-coding-agents/" rel="noopener noreferrer"&gt;exploratory analysis&lt;/a&gt; of coding-agent transcripts from seven METR technical staff during January 2026, METR explicitly warned about task selection and task substitution. People used agents where they expected help and sometimes completed useful but lower-value tasks they might not otherwise have done. The author therefore treated observed task-level time savings as a soft upper bound on productivity improvement, not a productivity multiplier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Review effort sits outside the numerator
&lt;/h2&gt;

&lt;p&gt;A merged pull request can require a five-minute check or several rounds of correction. Acceptance counts both as one success.&lt;/p&gt;

&lt;p&gt;For agent work, the hidden effort may include clarifying the ticket, &lt;a href="https://nulltensor.com/posts/ai-generated-test-coverage/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=ai-generated-test-coverage" rel="noopener noreferrer"&gt;restoring an assertion the agent weakened&lt;/a&gt;, checking tenant boundaries, rerunning a flaky test, or rewriting the change so it fits an existing design. The final merge tells us that a reviewer accepted the resulting code. It does not attribute how much of that result came from the agent.&lt;/p&gt;

&lt;p&gt;Rejection has a similar ambiguity. A valuable attempt at a difficult migration may uncover an undocumented dependency even if its pull request is not merged. A trivial change can be accepted immediately. If we reward teams only for the ratio, the safer strategy is to submit more of the second kind.&lt;/p&gt;

&lt;p&gt;The practical response is not to stop tracking acceptance. It is to retain the diagnostic signal and add the context needed to interpret it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure the work before, during and after review
&lt;/h2&gt;

&lt;p&gt;I would evaluate an agent workflow through five connected questions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Evidence to collect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What work reached the agent?&lt;/td&gt;
&lt;td&gt;Task category, expected impact, uncertainty, affected systems and a rough human effort estimate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Did it satisfy the task?&lt;/td&gt;
&lt;td&gt;Reviewed acceptance criteria and end-to-end checks against the resulting behaviour&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What did the human add?&lt;/td&gt;
&lt;td&gt;Review time, number and type of corrections, and unresolved decisions returned to the author&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Did it improve delivery?&lt;/td&gt;
&lt;td&gt;Lead time from task start to production, including waiting and rework&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Did the change remain healthy?&lt;/td&gt;
&lt;td&gt;Rollbacks, escaped defects, operational incidents and the intended product or business result&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The categories do not need an elaborate scoring model. Start by separating documentation, tests, fixes, features, migrations and performance work. Compare acceptance only within similar groups and show the number of attempted, abandoned, submitted and merged tasks at each stage.&lt;/p&gt;

&lt;p&gt;For task success, use the behaviour the change was meant to produce. OpenAI’s &lt;a href="https://openai.com/index/swe-lancer/" rel="noopener noreferrer"&gt;SWE-Lancer illustrates&lt;/a&gt; one way to make this concrete in an evaluation: it includes more than 1,400 real freelance software-engineering tasks with actual payouts, while independent tasks are graded using end-to-end tests reviewed three times by experienced engineers. It is a benchmark, not a template for measuring an internal team, but its design separates task outcome and economic context from whether a patch merely looks acceptable.&lt;/p&gt;

&lt;p&gt;Delivery measures can then connect the agent’s contribution to the surrounding system. &lt;a href="https://dora.dev/guides/dora-metrics/" rel="noopener noreferrer"&gt;DORA’s current software-delivery&lt;/a&gt; measures include change lead time, deployment frequency, failed deployment recovery time, change fail rate and deployment rework rate. These are team-level measures and should not be attributed entirely to the agent. They help reveal whether more accepted pull requests coincide with safer, faster delivery.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep acceptance rate in its proper place
&lt;/h2&gt;

&lt;p&gt;Pull-request acceptance rate is useful for finding friction. Segment it by task type, inspect rejection reasons, and &lt;a href="https://nulltensor.com/posts/six-feedback-loops-ai-first-engineering-pipeline/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=six-feedback-loops-ai-first-engineering-pipeline" rel="noopener noreferrer"&gt;track whether agent output routinely needs the same corrections&lt;/a&gt;. It can tell us where the workflow produces reviewable changes.&lt;/p&gt;

&lt;p&gt;The larger decision requires a wider denominator. Include the tasks the agent attempted, the tasks we chose not to give it, and the human work needed to turn its output into production software. Then connect those results to delivery and product outcomes.&lt;/p&gt;

&lt;p&gt;If I had to start over again, I would begin with one month of agent-assisted work. Label every task by type and uncertainty, record whether an attempt reached review, and sample the corrections behind accepted pull requests. We can then decide whether the agent is expanding the work the team can complete or becoming very efficient at the work that was already easiest to accept.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://nulltensor.com/posts/agent-pr-merge-rate/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=agent-pr-merge-rate" rel="noopener noreferrer"&gt;nulltensor.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>softwareengineering</category>
      <category>productivity</category>
      <category>codereview</category>
    </item>
    <item>
      <title>Your customer support AI is probably going to fail in production!</title>
      <dc:creator>Rohan Sen Sharma</dc:creator>
      <pubDate>Sat, 03 Oct 2026 03:00:00 +0000</pubDate>
      <link>https://dev.to/rss_holmes/your-customer-support-ai-is-probably-going-to-fail-in-production-51ba</link>
      <guid>https://dev.to/rss_holmes/your-customer-support-ai-is-probably-going-to-fail-in-production-51ba</guid>
      <description>&lt;p&gt;We were looking to implement a customer support AI agent. Our customer experience (CX) team evaluated several products, with varying levels of confidence in their suitability. A support-AI demo can produce a convincing answer to a product question. The harder test is whether it gives the right answer when the customer has omitted a detail, the documentation is incomplete, or the request needs to be escalated to an engineer.&lt;/p&gt;

&lt;p&gt;To avoid choosing a product based on its demo alone, we needed to formalise an AI agent evaluation process. We zeroed in on starting with a combination of 50 resolved tickets and a human-scored rubric. That is a manageable first evaluation, not a statistically established threshold for deployment.&lt;/p&gt;

&lt;p&gt;I wanted to understand which kinds of tickets AI could help us handle, how much review its answers would require, and where it would need to hand the work back to a human. We experimented and had several back-and-forth discussions between the engineering and customer success teams. I wanted to summarise what we learned to save other teams time.&lt;/p&gt;

&lt;p&gt;This is not a comprehensive guide. We are still learning how to operate this way, and success depends on several factors specific to each business. However, it can serve as a starting point for adapting the process to your own support workflow.&lt;/p&gt;

&lt;p&gt;Based on that experience, here is how I would structure an initial pilot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with one job for the AI
&lt;/h2&gt;

&lt;p&gt;“Automate support” leaves too much undefined.&lt;/p&gt;

&lt;p&gt;For this pilot, I would give the AI one job: draft the next customer-facing response using the ticket history and approved support information. It could propose a clarification or escalation when appropriate. A human would review every draft.&lt;/p&gt;

&lt;p&gt;That scope matters. We would be evaluating response drafting, not autonomous ticket resolution. A useful reply might move an investigation forward without resolving the problem.&lt;/p&gt;

&lt;p&gt;Take a customer who says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;My export has been processing for an hour. Can you restart it?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A useful response depends on what we know. Is the job still running? Has it failed? Can restarting it create duplicate work? Does the support team have permission to restart it?&lt;/p&gt;

&lt;p&gt;A confident “I’ve restarted your export” would be unacceptable if the AI had neither the tools nor the authority to do so.&lt;/p&gt;

&lt;p&gt;Before selecting tickets, write down what the AI can inspect, what it can recommend, and when it must escalate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the sample without giving away the answer
&lt;/h2&gt;

&lt;p&gt;Select 50 recent resolved tickets from the support queue, covering the work the proposed assistant would encounter.&lt;/p&gt;

&lt;p&gt;Include common how-to questions, troubleshooting, missing information, and cases that required escalation. Sample across customers and product areas and remove duplicates that would give one recurring incident disproportionate influence.&lt;/p&gt;

&lt;p&gt;If we deliberately include extra difficult cases, report them separately. Their failure rate should not represent the normal queue.&lt;/p&gt;

&lt;p&gt;For each ticket, choose a point where the AI would have been asked to help. Build two separate records:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Input:&lt;/strong&gt; the customer message, preceding conversation, and permitted context available at that point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reviewer reference:&lt;/strong&gt; the eventual resolution, relevant evidence, and acceptable next steps.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep subsequent replies and resolution notes out of the AI’s input. Otherwise, we would be testing whether it can restate an answer already present in the context. Giving the AI information that became available later introduces lookahead bias and makes the evaluation unreliable.&lt;/p&gt;

&lt;p&gt;Check the retrieval source too. Removing the resolution from the prompt achieves little if the AI can retrieve the complete closed ticket.&lt;/p&gt;

&lt;p&gt;Use an approved environment and remove customer identifiers that are unnecessary for the task. Preserve details that materially affect the answer, such as the product version or relevant account configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define the rubric before reading the outputs
&lt;/h2&gt;

&lt;p&gt;I would score each draft across five dimensions, using a simple 0–2 scale.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;0: unacceptable&lt;/th&gt;
&lt;th&gt;1: needs correction&lt;/th&gt;
&lt;th&gt;2: meets expectations&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Factual correctness&lt;/td&gt;
&lt;td&gt;Gives incorrect or invented information&lt;/td&gt;
&lt;td&gt;Contains a material ambiguity or imprecision&lt;/td&gt;
&lt;td&gt;Claims agree with the available evidence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Appropriate next step&lt;/td&gt;
&lt;td&gt;Recommends an unsuitable action&lt;/td&gt;
&lt;td&gt;Direction is useful but incomplete&lt;/td&gt;
&lt;td&gt;Answers, clarifies, or escalates appropriately&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Use of context&lt;/td&gt;
&lt;td&gt;Ignores a relevant fact&lt;/td&gt;
&lt;td&gt;Uses some context but misses an important detail&lt;/td&gt;
&lt;td&gt;Accounts for the details needed at this stage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Policy and authority&lt;/td&gt;
&lt;td&gt;Crosses a boundary or claims an unperformed action&lt;/td&gt;
&lt;td&gt;Leaves a permission or policy condition unclear&lt;/td&gt;
&lt;td&gt;Stays within the defined boundaries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Customer usability&lt;/td&gt;
&lt;td&gt;Confusing or unusable&lt;/td&gt;
&lt;td&gt;Requires avoidable rewriting&lt;/td&gt;
&lt;td&gt;Clear, specific, and actionable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rubric needs examples from our own support workflow. “Appropriate escalation” means little until we define which requests require it and what information the handoff must contain.&lt;/p&gt;

&lt;p&gt;I would record critical failures separately from the total score. Exposing another customer’s information or falsely claiming that an action was completed should fail the draft regardless of how well it scores elsewhere.&lt;/p&gt;

&lt;p&gt;Anthropic’s &lt;a href="https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents" rel="noopener noreferrer"&gt;guidance on evaluating agents&lt;/a&gt; distinguishes the agent’s transcript from the actual outcome. That distinction applies here: a response claiming that an export was restarted is not evidence that the restart happened.&lt;/p&gt;

&lt;p&gt;The historical support reply should also be treated as a reference, rather than the only acceptable wording. A different response might be equally valid, or better supported by the information available.&lt;/p&gt;

&lt;h2&gt;
  
  
  Calibrate the reviewers, then freeze the setup
&lt;/h2&gt;

&lt;p&gt;Within the 50 tickets, I would use 30 for development and reserve 20 for evaluation after the setup was fixed. This is similar to how backtests are run in financial simulations.&lt;/p&gt;

&lt;p&gt;Start by having two experienced support reviewers independently score the same five development cases. Compare their scores and discuss disagreements.&lt;/p&gt;

&lt;p&gt;If one reviewer rewards a direct answer while another expects clarification, the issue may be an undefined support rule. Resolve that before judging the remaining outputs.&lt;/p&gt;

&lt;p&gt;OpenAI’s &lt;a href="https://developers.openai.com/api/docs/guides/evaluation-best-practices" rel="noopener noreferrer"&gt;evaluation guidance&lt;/a&gt; recommends clear, task-specific criteria and human calibration. For this pilot, calibration gives us a shared interpretation of what a good response looks like.&lt;/p&gt;

&lt;p&gt;Use the development tickets to improve instructions, retrieval, and escalation guidance. Then freeze the model, prompt, knowledge sources, and settings before running the reserved tickets.&lt;/p&gt;

&lt;p&gt;If we tune against those reserved results, they become development data. We need fresh tickets for the next independent check.&lt;/p&gt;

&lt;p&gt;For every output, retain the draft, rubric scores, critical-failure flag, reviewer correction, and time spent reviewing and editing. Where practical, have both reviewers independently score the reserved set and reconcile disagreements.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure the work left for the human
&lt;/h2&gt;

&lt;p&gt;A high average rubric score would be insufficient if every answer still required substantial checking.&lt;/p&gt;

&lt;p&gt;Alongside the scores, classify each draft as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Usable without edits.&lt;/li&gt;
&lt;li&gt;Usable after minor edits.&lt;/li&gt;
&lt;li&gt;Requiring substantial correction.&lt;/li&gt;
&lt;li&gt;Unusable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Record appropriate escalations separately from unnecessary ones. Otherwise, an assistant that escalates everything could appear dependable while contributing little.&lt;/p&gt;

&lt;p&gt;For a time comparison, ask another reviewer to draft responses without AI from the same input context. Avoid having someone write the baseline immediately after seeing the AI’s answer. Compare human preparation time with the time a human spends reviewing and editing the AI’s draft, and record generation latency separately.&lt;/p&gt;

&lt;p&gt;The comparison remains a small offline exercise. It cannot establish customer satisfaction, a reduction in reopened tickets, or end-to-end resolution time.&lt;/p&gt;

&lt;p&gt;Report counts as well as percentages. “Three of six troubleshooting drafts needed substantial correction” is more useful than a single average across all 50 tickets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use the result to choose a narrower next step
&lt;/h2&gt;

&lt;p&gt;Before running the next iteration, agree on the conditions for progressing: acceptable answer quality, no observed critical failures, manageable review effort, and clear escalation behaviour.&lt;/p&gt;

&lt;p&gt;Passing those conditions would justify a limited, human-reviewed live trial. It would not establish readiness for automatic sending.&lt;/p&gt;

&lt;p&gt;In our case, the results supported using AI to draft routine how-to answers while leaving troubleshooting with the support team. Another team might find that missing documentation is the main constraint. Either finding gives us a concrete next action.&lt;/p&gt;

&lt;p&gt;I would finish the pilot by assigning each recurring failure to an owner and a correction: improve a source, change an instruction, clarify a rule, or restrict the supported scope. Preserve those cases as regression checks, then evaluate the revised system on fresh tickets before expanding its role.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://nulltensor.com/posts/support-ai-pilot-50-resolved-tickets/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=support-ai-pilot-50-resolved-tickets" rel="noopener noreferrer"&gt;nulltensor.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>testing</category>
      <category>startup</category>
    </item>
    <item>
      <title>Using AI to Investigate a Slow Query: Why Django icontains Skipped Our pg_trgm Index</title>
      <dc:creator>Rohan Sen Sharma</dc:creator>
      <pubDate>Fri, 02 Oct 2026 03:00:00 +0000</pubDate>
      <link>https://dev.to/rss_holmes/using-ai-to-investigate-a-slow-query-why-django-icontains-skipped-our-pgtrgm-index-54j7</link>
      <guid>https://dev.to/rss_holmes/using-ai-to-investigate-a-slow-query-why-django-icontains-skipped-our-pgtrgm-index-54j7</guid>
      <description>&lt;p&gt;We used AI to find out why Django's icontains filter was quietly bypassing our PostgreSQL pg_trgm trigram index while product searches slowed to several seconds.&lt;/p&gt;

&lt;p&gt;We had recently migrated our database from MySQL to PostgreSQL and were benchmarking the new database’s performance. During this exercise, we found that product searches were taking several seconds despite having trigram indexes in place. The code looked straightforward. The indexes existed. However, the generated SQL was asking PostgreSQL to search a different expression from the one we had indexed.&lt;/p&gt;

&lt;p&gt;To debug this I sought the help of my trusted claude and asked it to analyse different parts of my stack. Let us walk through that investigation and what it tells us about using AI when the behaviour of a system is unfamiliar.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;An unfamiliar error becomes easier to investigate when we can connect it to something concrete: the request that failed, the code it executed, and the work the underlying system performed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Start with what the system is doing
&lt;/h2&gt;

&lt;p&gt;Our application stores products belonging to different companies. During the investigation, we recorded the following comparison at approximately 4,908 requests per minute, using the same &lt;code&gt;db.r6i.large&lt;/code&gt; instance class:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;MySQL comparison&lt;/th&gt;
&lt;th&gt;PostgreSQL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Database CPU utilisation&lt;/td&gt;
&lt;td&gt;42.7%&lt;/td&gt;
&lt;td&gt;94.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Average database time per request&lt;/td&gt;
&lt;td&gt;72 ms&lt;/td&gt;
&lt;td&gt;444 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Product-list database time during the peak window&lt;/td&gt;
&lt;td&gt;112 ms&lt;/td&gt;
&lt;td&gt;4.67 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These numbers established the scale of the problem. They did not establish why it was happening. Similar throughput and instance size still leave differences in query mix, configuration, and execution plans.&lt;/p&gt;

&lt;p&gt;In investigations like this, New Relic helps us locate the affected operation, while CloudWatch and RDS metrics provide the surrounding resource context. We then need to connect that operation to the actual code and database query. Before asking AI for a fix, give it the exact error, the affected operation, the timestamp, and the relevant application code. Include what you expected to happen and what happened instead.&lt;/p&gt;

&lt;p&gt;A useful starting question is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What does this evidence establish, which explanations remain possible, and what should we inspect next to distinguish them?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This encourages an investigation that can progress as new evidence arrives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Look below the abstraction
&lt;/h2&gt;

&lt;p&gt;Our product search used Django’s case-insensitive lookup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product_name__icontains&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the configuration we investigated, the generated PostgreSQL expression was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPPER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product_name&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="k"&gt;UPPER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'%term%'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We had created trigram indexes on the raw columns, using these indexed expressions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;product_name&lt;/span&gt; &lt;span class="n"&gt;gin_trgm_ops&lt;/span&gt;
&lt;span class="n"&gt;itemid&lt;/span&gt; &lt;span class="n"&gt;gin_trgm_ops&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important detail was the &lt;code&gt;UPPER()&lt;/code&gt; transformation.&lt;/p&gt;

&lt;p&gt;The product-name index covered the raw column, while the query searched its uppercase representation. Our &lt;code&gt;EXPLAIN&lt;/code&gt; output showed that this query form bypassed the product-name trigram index. PostgreSQL read the tenant’s candidate products and applied the text filter row by row.&lt;/p&gt;

&lt;p&gt;This explains why checking that an index exists is insufficient. We need to understand whether the query can use it.&lt;/p&gt;

&lt;p&gt;There is a second question: what work does using that index actually require? In &lt;a href="https://nulltensor.com/posts/the-day-my-index-optimization-melted/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=the-day-my-index-optimization-melted" rel="noopener noreferrer"&gt;the MySQL index change that sent our database CPU to 100%&lt;/a&gt;, the problem was the cost of the selected access path. Here, the query expression prevented PostgreSQL from using the intended index. Both investigations needed the query plan to explain what the database was doing.&lt;/p&gt;

&lt;p&gt;It also gives AI a much more specific problem. With the ORM code alone, it has limited evidence. With the generated SQL, index definition, and query plan together, we can ask it to examine the relationship between them.&lt;/p&gt;

&lt;p&gt;So I followed up with a prompt along these lines:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Compare the expression being filtered with the expression being indexed. Explain which plan nodes support an index mismatch, and identify any assumptions that still need verification.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The useful principle is to ask the assistant to connect its explanation to observable details. We want to understand what happened beneath the abstraction that reported the failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make a change that preserves behaviour
&lt;/h2&gt;

&lt;p&gt;Our fix aligned the query with the existing index.&lt;/p&gt;

&lt;p&gt;Moving from &lt;code&gt;icontains&lt;/code&gt; to &lt;code&gt;contains&lt;/code&gt; removed the &lt;code&gt;UPPER()&lt;/code&gt; transformation. We also configured case-insensitive collation on the product-name column to preserve the intended search behaviour.&lt;/p&gt;

&lt;p&gt;Both parts mattered. A query becoming faster would not be sufficient if it stopped returning products that users expected to find.&lt;/p&gt;

&lt;p&gt;I would treat an AI-generated recommendation with the same requirement: explain both the performance mechanism and the behaviour that must remain unchanged.&lt;/p&gt;

&lt;p&gt;In this case, that means checking case sensitivity, representative search terms, and the returned results alongside the execution plan. The lookup and collation changes describe our tested configuration; they are not a general recommendation to replace every &lt;code&gt;icontains&lt;/code&gt; call.&lt;/p&gt;

&lt;p&gt;PostgreSQL supports trigram-based indexing for &lt;code&gt;LIKE&lt;/code&gt; and &lt;code&gt;ILIKE&lt;/code&gt;, but effectiveness depends on the query and the trigrams that can be extracted from its search pattern. &lt;a href="https://www.postgresql.org/docs/current/pgtrgm.html" rel="noopener noreferrer"&gt;PostgreSQL &lt;code&gt;pg_trgm&lt;/code&gt; documentation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The plan and the result checks are what let us move from a plausible change to a supported one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validate the explanation as well as the fix
&lt;/h2&gt;

&lt;p&gt;We validated the revised queries in PostgreSQL staging against a company with approximately 1.15 million products.&lt;/p&gt;

&lt;p&gt;The raw-column &lt;code&gt;LIKE&lt;/code&gt; queries used the existing GIN trigram indexes and completed in 77–90 ms. The previous &lt;code&gt;UPPER(...) LIKE UPPER(...)&lt;/code&gt; queries exceeded our 12-second database timeout.&lt;/p&gt;

&lt;p&gt;That gives a lower-bound improvement of more than 130× for these measured cases. The exact speed-up is unknown because we stopped the old queries before they completed(timed out).&lt;/p&gt;

&lt;p&gt;This is where an AI-assisted investigation needs a feedback loop. Return the new plan, timings, and correctness results to the assistant. Ask whether they support the proposed explanation and what remains unresolved. Sometimes an anomaly can be attributed to a combination of reasons. AI helps you better calibrate if the new data conforms to the fixes we introduced.&lt;/p&gt;

&lt;h2&gt;
  
  
  Carry the method into the next error
&lt;/h2&gt;

&lt;p&gt;The final finding was SPECIFIC to this scenario : our query expression and index expression did not match. However the AI assisted debugging method applies more broadly.&lt;/p&gt;

&lt;p&gt;When using AI to investigate an unfamiliar error or stack trace, start with the failing operation. Gather the relevant code and runtime evidence. Ask for explanations that can be tested, then bring the results back into the investigation.&lt;/p&gt;

&lt;p&gt;Google’s &lt;em&gt;Effective Troubleshooting&lt;/em&gt; chapter describes this process as forming hypotheses and testing them against observations. AI fits naturally into that process when its explanations remain tied to evidence. &lt;a href="https://sre.google/sre-book/effective-troubleshooting/" rel="noopener noreferrer"&gt;Google SRE: Effective Troubleshooting&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For a lean engineering team, the lasting value is in making the investigation repeatable. Preserve the symptom, the evidence, the change, and the validation checks. The next unfamiliar failure then starts with a better set of questions and a tested way to answer them.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://nulltensor.com/posts/using-ai-to-investigate-an-unfamiliar-error-or-stack-trace/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=using-ai-to-investigate-an-unfamiliar-error-or-stack-trace" rel="noopener noreferrer"&gt;nulltensor.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>django</category>
      <category>postgres</category>
      <category>python</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
