<?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>Forem: Ed G</title>
    <description>The latest articles on Forem by Ed G (@writerviber).</description>
    <link>https://forem.com/writerviber</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3704730%2Fd9f89f93-c17e-4b08-b792-d29be227c6f2.png</url>
      <title>Forem: Ed G</title>
      <link>https://forem.com/writerviber</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/writerviber"/>
    <language>en</language>
    <item>
      <title>not-claw: A Personal AI Agent That Lives in Telegram and Thinks in Notion</title>
      <dc:creator>Ed G</dc:creator>
      <pubDate>Sun, 29 Mar 2026 17:32:32 +0000</pubDate>
      <link>https://forem.com/writerviber/not-claw-a-personal-ai-agent-that-lives-in-telegram-and-thinks-in-notion-2cla</link>
      <guid>https://forem.com/writerviber/not-claw-a-personal-ai-agent-that-lives-in-telegram-and-thinks-in-notion-2cla</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/notion-2026-03-04"&gt;Notion MCP Challenge&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;not-claw&lt;/strong&gt; is a self-hosted AI agent that talks to you on Telegram and uses Notion as its entire brain, memory, skills, tasks, and identity, accessed through the Notion MCP server.&lt;/p&gt;

&lt;p&gt;Inspired by &lt;a href="https://openclaw.ai" rel="noopener noreferrer"&gt;OpenClaw&lt;/a&gt;, the open-source personal agent framework that stores state as Markdown files on disk, not-claw replaces that flat-file layer with Notion. The agent reads a Soul page to know who it is. It reads a Memory page to remember what it's learned. It queries a Skills database for instructions on how to do things. It manages a Tasks database as its work queue. Every 30 minutes, a heartbeat wakes it up to work through pending tasks without being asked.&lt;/p&gt;

&lt;p&gt;The entire agent state is visible and editable in Notion. You can open the Skills database on your phone, write a new instruction, and the next time the agent runs it'll use it. You can see exactly what happened on every heartbeat in the log. Nothing is locked in a proprietary format, it's just Notion pages.&lt;/p&gt;

&lt;p&gt;The agent also knows your timezone (configured via &lt;code&gt;TIMEZONE&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt;), so every prompt includes the exact local time.&lt;/p&gt;

&lt;p&gt;The stack: Anthropic SDK for reasoning (Sonnet for interactive chat, Haiku for heartbeats — both configurable via &lt;code&gt;MODEL_INTERACTIVE&lt;/code&gt; and &lt;code&gt;MODEL_HEARTBEAT&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt;), &lt;code&gt;@notionhq/notion-mcp-server&lt;/code&gt; for Notion access via MCP, &lt;code&gt;@modelcontextprotocol/sdk&lt;/code&gt; for the MCP client, and grammy for the Telegram bot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Video Demo
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://youtu.be/DEoUTTnxCT0" rel="noopener noreferrer"&gt;Watch a brief demo on YouTube&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Show us the code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/grzetich" rel="noopener noreferrer"&gt;
        grzetich
      &lt;/a&gt; / &lt;a href="https://github.com/grzetich/not-claw" rel="noopener noreferrer"&gt;
        not-claw
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;not-claw&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;A personal AI agent that lives in Telegram and thinks in Notion.&lt;/p&gt;
&lt;p&gt;Inspired by &lt;a href="https://openclaw.ai" rel="nofollow noopener noreferrer"&gt;OpenClaw&lt;/a&gt; — but instead of Markdown files on disk, the agent's entire brain is a Notion workspace accessed through the &lt;a href="https://github.com/notionhq/notion-mcp-server" rel="noopener noreferrer"&gt;Notion MCP server&lt;/a&gt;. Soul, memory, skills, tasks, and heartbeat logs are all Notion pages and databases that both you and the agent read and write to.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;How it works&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;&lt;pre class="notranslate"&gt;&lt;code&gt;You (Telegram)
      |
      v
 +-----------+     MCP (stdio)     +---------------------+
 |  Gateway   | -----------------&amp;gt;  |  Notion MCP Server  |
 | gateway.js |     Agent loop      | @notionhq/          |
 +-----------+  &amp;lt;-  agent.js  -&amp;gt;    |  notion-mcp-server   |
      ^                             +---------+-----------+
      |                                       |
 +-----------+                                v
 | Heartbeat |  &amp;lt;- node-cron         Notion Workspace
 |heartbeat.js|   (every 30 min)     - Soul page
 +-----------+                       - Memory page
                                     - Skills DB
                                     - Tasks DB
                                     - Heartbeat log
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;You message the bot on Telegram&lt;/li&gt;
&lt;li&gt;The gateway passes your message to the agent loop (&lt;code&gt;agent.js&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Claude reads…&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/grzetich/not-claw" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;Core files:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mcp-client.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Spawns the Notion MCP server as a stdio subprocess, discovers 22 tools at startup, bridges tool calls between Claude and Notion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;agent.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Agentic loop — sends messages to Claude with MCP tools, executes tool calls, feeds results back, repeats until done&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gateway.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Telegram bot (grammy) — relays messages between you and the agent, owner-only auth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;heartbeat.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cron job that wakes the agent every 30 minutes to work the task queue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;index.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Entry point — boots gateway, heartbeat, and reminders together&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How I Used Notion MCP
&lt;/h2&gt;

&lt;p&gt;Notion MCP is the foundation of the entire system. The agent has zero hardcoded Notion API calls. Every read, write, search, and update goes through MCP tool calls that Claude discovers and invokes on its own.&lt;/p&gt;

&lt;h3&gt;
  
  
  Connecting to Notion MCP
&lt;/h3&gt;

&lt;p&gt;At startup, &lt;code&gt;mcp-client.js&lt;/code&gt; spawns the official &lt;code&gt;@notionhq/notion-mcp-server&lt;/code&gt; as a stdio subprocess:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Client&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@modelcontextprotocol/sdk/client/index.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;StdioClientTransport&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@modelcontextprotocol/sdk/client/stdio.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;transport&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StdioClientTransport&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;npx&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-y&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@notionhq/notion-mcp-server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;OPENAPI_MCP_HEADERS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NOTION_API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Notion-Version&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2022-06-28&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;not-claw&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1.0.0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transport&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;tools&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listTools&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// → 22 tools: API-post-search, API-get-block-children,&lt;/span&gt;
&lt;span class="c1"&gt;//   API-patch-page, API-query-data-source, etc.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These 22 tools are converted to Anthropic tool-use format and passed to Claude. Claude decides which tools to call, in what order, with what arguments. The MCP client executes them and returns results. The agent code is just the loop — Notion MCP does the heavy lifting.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Notion MCP unlocks
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Notion as the agent's operating system.&lt;/strong&gt; OpenClaw uses a directory of Markdown files (&lt;code&gt;SOUL.md&lt;/code&gt;, &lt;code&gt;MEMORY.md&lt;/code&gt;, &lt;code&gt;skills/*.md&lt;/code&gt;). not-claw replaces all of it with Notion pages and databases:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;OpenClaw&lt;/th&gt;
&lt;th&gt;not-claw (via Notion MCP)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SOUL.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Soul page — agent identity, personality, owner context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MEMORY.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Memory page — long-term facts, appended each session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;skills/&lt;/code&gt; directory&lt;/td&gt;
&lt;td&gt;Skills database — each page = one skill with instructions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Task queue&lt;/td&gt;
&lt;td&gt;Tasks database — status, priority, notes, timestamps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Heartbeat log&lt;/td&gt;
&lt;td&gt;Heartbeat database — record of every proactive run&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Self-improving agent.&lt;/strong&gt; Because Notion MCP gives full read/write access, the agent can write new skill pages back to the Skills database. Tell it "figure out how to summarize a webpage and save it as a skill" — it writes the instructions to Notion, and every future session can use that skill. This is the OpenClaw self-improvement loop, rebuilt on Notion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proactive work via heartbeat.&lt;/strong&gt; Every 30 minutes, a cron job fires. Before calling Claude, it does a pre-check: queries the Tasks database directly via MCP (no Claude call, $0 cost). If nothing is pending, it skips entirely. If there are tasks, it runs the agent using Haiku (~60x cheaper than Sonnet) to pick up the highest-priority task, do the work, update the task status, log the run to the Heartbeat database, and message you the result on Telegram. Interactive messages still use Sonnet for quality. You didn't ask it to do anything — it just checked Notion and got to work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two-way collaboration, not just a chatbot.&lt;/strong&gt; Because the brain is in Notion, you and the agent are equal participants in the same workspace. Tell the bot to add a task via Telegram, then open Notion later to add details, reprioritize, or mark it done yourself. Write a skill page directly in Notion and the agent uses it next session. Edit the Memory page to correct something the agent got wrong. Add tasks straight to the database and the heartbeat finds them. The data isn't locked behind the bot — Notion is the shared workspace, and both you and the agent read and write to it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The agentic loop
&lt;/h3&gt;

&lt;p&gt;The loop in &lt;code&gt;agent.js&lt;/code&gt; is simple because MCP handles the complexity:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Connect to Notion MCP, discover tools&lt;/li&gt;
&lt;li&gt;Send user message + tools to Claude&lt;/li&gt;
&lt;li&gt;If Claude calls tools → execute via MCP client, feed results back, repeat&lt;/li&gt;
&lt;li&gt;If Claude returns text → send to user via Telegram&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Claude typically makes 3-8 tool calls per interaction: read Soul, read Memory, search Skills, then do whatever the user asked (create a task, query the database, update a page, etc.).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/grzetich/not-claw
&lt;span class="nb"&gt;cd &lt;/span&gt;not-claw
npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;span class="c"&gt;# See NOTION_SETUP.md for workspace configuration&lt;/span&gt;
&lt;span class="c"&gt;# Set TIMEZONE in .env to your IANA timezone (e.g. America/New_York)&lt;/span&gt;
npm start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>devchallenge</category>
      <category>notionchallenge</category>
      <category>mcp</category>
      <category>ai</category>
    </item>
    <item>
      <title>How I Built a Contamination-Free API to Test AI Doc Formats</title>
      <dc:creator>Ed G</dc:creator>
      <pubDate>Tue, 03 Mar 2026 17:13:19 +0000</pubDate>
      <link>https://forem.com/writerviber/how-i-built-a-contamination-free-api-to-test-ai-doc-formats-460f</link>
      <guid>https://forem.com/writerviber/how-i-built-a-contamination-free-api-to-test-ai-doc-formats-460f</guid>
      <description>&lt;p&gt;If you test how well AI tools use Stripe's API docs, how do you know what you're measuring? The AI might be reading your docs, or it might be drawing on patterns it already learned. You can't tell. That's the contamination problem, and it almost killed this research before it started.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with popular APIs
&lt;/h2&gt;

&lt;p&gt;When I set out to test whether documentation format affects AI code generation, the obvious approach was to use a well-known API. Stripe, Twilio, GitHub. Pick one, document it in different formats, generate code, compare the results.&lt;/p&gt;

&lt;p&gt;Then I thought about it for five minutes and realized the whole thing was flawed.&lt;/p&gt;

&lt;p&gt;Every popular API is everywhere. GitHub repos, Stack Overflow answers, blog tutorials, coding bootcamp projects, YouTube walkthroughs. All of that is in the training data for every major AI model. When you ask Claude or GPT-4o to generate Stripe integration code, some unknown percentage of its output comes from memorized patterns, not from reading the documentation you gave it.&lt;/p&gt;

&lt;p&gt;That means you can't isolate the variable. You're not measuring "how well does the AI read this documentation format." You're measuring some unknowable mix of documentation comprehension and training data recall. The results would be meaningless.&lt;/p&gt;

&lt;h2&gt;
  
  
  The solution: build something that doesn't exist
&lt;/h2&gt;

&lt;p&gt;The fix was straightforward in concept: build APIs that have never existed on the public internet. No GitHub repos. No Stack Overflow questions. No tutorials. No training data exposure of any kind.&lt;/p&gt;

&lt;p&gt;I built two of them.&lt;/p&gt;

&lt;p&gt;The first is &lt;strong&gt;BookClub&lt;/strong&gt;, a book club management API with 6 endpoints. Members, books, meetings, notes. Simple enough that even a small model should be able to generate working integration code, but with enough structure to test real patterns like nested resources and input validation.&lt;/p&gt;

&lt;p&gt;The second is &lt;strong&gt;EventForge&lt;/strong&gt;, an event management API with 10 endpoints. This one is intentionally more complex: HMAC webhook verification, cursor-based pagination, PATCH operations for partial updates. The kind of patterns that stress-test whether a model truly understands the documentation or is just pattern-matching.&lt;/p&gt;

&lt;p&gt;Two APIs at different complexity levels let me ask a question that one API alone can't answer: does the relationship between format and code generation change as the API gets harder?&lt;/p&gt;

&lt;h2&gt;
  
  
  Why two complexity levels matter
&lt;/h2&gt;

&lt;p&gt;If I'd only built BookClub, I might have concluded that documentation format doesn't matter much. The simple API is forgiving. Most models, most formats, most of the time, it works.&lt;/p&gt;

&lt;p&gt;EventForge tells a different story. When you push the complexity up, the cracks in certain formats become visible. Patterns that were fine for a 6-endpoint API fall apart on a 10-endpoint API with authentication, pagination, and partial updates. The interaction between format and complexity is where the most important findings live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same information, four formats
&lt;/h2&gt;

&lt;p&gt;With the APIs built, I documented each one in four formats: YAML, OpenAPI 3.0, a novel format I created called DON (Documentation-Optimized Notation), and Markdown. Every format describes the exact same endpoints with the exact same parameters and the exact same constraints. The only thing that changes is how the information is structured.&lt;/p&gt;

&lt;p&gt;This is critical. If the formats contained different information, you couldn't attribute differences in code generation to format. The four docs are informationally identical. They differ only in structure, verbosity, and notation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test harness
&lt;/h2&gt;

&lt;p&gt;For each combination of API, format, and model, I ran integration tests. Give the model a documentation spec and a task ("write a function that adds a book to the club"), capture the generated code, and test whether it actually works against the live API.&lt;/p&gt;

&lt;p&gt;Temperature set to 0.0 across all runs to minimize randomness. Multiple runs per combination to check for consistency. Over 21,000 total test executions across four AI models, from local models you can run on a laptop to frontier cloud APIs.&lt;/p&gt;

&lt;p&gt;The result is a dataset where every variable is controlled except the one I'm testing. When the generated code differs between formats, I know the format caused it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What contamination-free data lets you see
&lt;/h2&gt;

&lt;p&gt;With clean data, patterns emerge that would be invisible in a contaminated test. You can see exactly how a model responds to the documentation in front of it, without the noise of prior training on the same API.&lt;/p&gt;

&lt;p&gt;Some of those patterns were surprising. Some confirmed what I expected. All of them required contamination-free data to be credible.&lt;/p&gt;

&lt;p&gt;The full findings are in the book. But the methodology point stands on its own: if you're testing how AI tools interact with documentation, you need to control for training data contamination. Otherwise you're measuring the model's memory, not its comprehension.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The full results, including what those patterns actually are, are coming in &lt;em&gt;Tokens Not Jokin'&lt;/em&gt;, out this month.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://doc-cost.vercel.app/" rel="noopener noreferrer"&gt;Try the free Docs Cost Calculator →&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://leanpub.com/tokensnotjokin" rel="noopener noreferrer"&gt;Get the book on Leanpub →&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Problem Nobody's Measuring</title>
      <dc:creator>Ed G</dc:creator>
      <pubDate>Wed, 25 Feb 2026 15:48:37 +0000</pubDate>
      <link>https://forem.com/writerviber/the-problem-nobodys-measuring-22db</link>
      <guid>https://forem.com/writerviber/the-problem-nobodys-measuring-22db</guid>
      <description>&lt;p&gt;Your company tracks API latency, uptime, error rates, and developer satisfaction scores. But nobody's tracking what it costs AI tools to read your API documentation.&lt;/p&gt;

&lt;p&gt;Open your observability dashboard. You can probably see API response times broken down by endpoint. Error rates by status code. Maybe even developer satisfaction from your last survey. If you're sophisticated, you're tracking time-to-first-hello-world for new integrations.&lt;/p&gt;

&lt;p&gt;Now tell me: how many tokens does it cost an AI coding tool to read your API docs?&lt;/p&gt;

&lt;p&gt;You don't know. Nobody does. There's no dashboard for this. No metric.&lt;/p&gt;

&lt;h2&gt;
  
  
  The invisible transaction
&lt;/h2&gt;

&lt;p&gt;Every time a developer points Cursor, Copilot, or Claude at your API documentation, a transaction happens. The AI tool reads your docs, converts them into tokens, and uses those tokens to generate integration code. The format of your documentation determines how many tokens that costs.&lt;/p&gt;

&lt;p&gt;This isn't theoretical. If your docs are in a verbose format, the AI tool consumes more of its context window just reading the spec. That leaves less room for reasoning, for the developer's project context, and for generating quality code. In the worst case, the documentation fills so much of the context window that the model can't produce working code at all.&lt;/p&gt;

&lt;p&gt;And it compounds. Every endpoint. Every developer accessing your docs. Every day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nobody's noticed
&lt;/h2&gt;

&lt;p&gt;Documentation has traditionally been measured by human metrics: readability, completeness, accuracy, time-to-find-information. Those metrics still matter. But they're designed for a world where humans are the primary (or only) consumers of your docs.&lt;/p&gt;

&lt;p&gt;That world is shifting. AI coding tools are increasingly the first reader of your API documentation. The developer asks the tool to integrate with your API, and the tool goes and reads your docs before generating code. The developer may never visit your docs site at all.&lt;/p&gt;

&lt;p&gt;When the primary consumer changes, the metrics need to change too. But the tech writing community hasn't caught up. We're still optimizing for human readability while AI tools are silently consuming our docs in a completely different way, and paying a cost that nobody sees.&lt;/p&gt;

&lt;h2&gt;
  
  
  The format question
&lt;/h2&gt;

&lt;p&gt;Here's what makes this interesting: the same API information, documented in different formats, produces different token costs. Sometimes dramatically different. Two docs describing the exact same endpoints, the exact same parameters, the exact same constraints, but one could cost significantly more tokens than the other.&lt;/p&gt;

&lt;p&gt;That raises an obvious question: does the format also affect the quality of the code the AI generates? Or just the cost?&lt;/p&gt;

&lt;p&gt;And a harder question: if you tried to test this with a popular API like Stripe's, how would you know whether the AI is reading your docs or just drawing on patterns it already learned from training data? Every popular API is all over GitHub, Stack Overflow, and tutorial sites. You can't isolate the variable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm doing about it
&lt;/h2&gt;

&lt;p&gt;I've been researching this for the past year. I built two control APIs from scratch, APIs with no public footprint and no prior exposure in any training dataset. I documented each one in four different formats and ran over 21,000 integration tests across four AI models, from local models you can run on a laptop to frontier cloud APIs.&lt;/p&gt;

&lt;p&gt;Clean data. No contamination question.&lt;/p&gt;

&lt;p&gt;The results are coming in a book called &lt;em&gt;Tokens Not Jokin'&lt;/em&gt;, out in March. But you don't have to wait for the book to start understanding the problem.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;See what your docs cost.&lt;/strong&gt; I built a free calculator that shows the token cost of your API documentation in real time. Paste your spec, see the numbers. It runs entirely in your browser.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://doc-cost.vercel.app/" rel="noopener noreferrer"&gt;Try the Docs Cost Calculator →&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://leanpub.com/tokensnotjokin" rel="noopener noreferrer"&gt;Pre-order the book on Leanpub →&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>devrel</category>
      <category>documentation</category>
    </item>
    <item>
      <title>Building a WebMCP Kanban Board: Browser-Native AI Integration With Zero Backend</title>
      <dc:creator>Ed G</dc:creator>
      <pubDate>Mon, 23 Feb 2026 02:10:19 +0000</pubDate>
      <link>https://forem.com/writerviber/building-a-webmcp-kanban-board-browser-native-ai-integration-with-zero-backend-4371</link>
      <guid>https://forem.com/writerviber/building-a-webmcp-kanban-board-browser-native-ai-integration-with-zero-backend-4371</guid>
      <description>&lt;p&gt;&lt;em&gt;A complete implementation of a kanban board with 8 AI-callable tools, connected to Claude through Chrome DevTools MCP. No backend. No database. No API server.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What is WebMCP?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://webmachinelearning.github.io/webmcp/" rel="noopener noreferrer"&gt;WebMCP&lt;/a&gt; is a W3C draft browser API that lets websites register tools AI agents can discover and call -- directly through the browser. The API surface is &lt;code&gt;navigator.modelContext&lt;/code&gt;: a standard interface where web applications declare structured tools, and any conforming AI agent can invoke them without proprietary bridges, plugins, or server-side orchestration.&lt;/p&gt;

&lt;p&gt;The spec is still a draft, but the &lt;a href="https://www.npmjs.com/package/@mcp-b/global" rel="noopener noreferrer"&gt;@mcp-b/global&lt;/a&gt; polyfill makes it usable today. A website registers its tools at page load, an agent discovers them via &lt;code&gt;navigator.modelContext.tools&lt;/code&gt;, and calls them with structured input. The return value is plain JSON.&lt;/p&gt;

&lt;p&gt;This post walks through a complete implementation: a kanban board built as a pure client-side React app with 8 AI-callable tools, connected to Claude through Chrome DevTools MCP. No backend. No database. No API server.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why a Kanban Board?
&lt;/h2&gt;

&lt;p&gt;Three properties make a kanban board unusually effective for demonstrating WebMCP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Visible state changes.&lt;/strong&gt; When an AI agent creates a card or moves it between columns, the result is immediately visible. The agent writes to React state, React re-renders, and the card appears on screen in the same frame. You watch the agent work in real time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rich, composable tools.&lt;/strong&gt; A kanban board supports more than simple CRUD. The 8 tools compose into multi-step workflows: read the board, create cards, add labels, move cards between columns, summarize a column, reprioritize by urgency. This demonstrates that WebMCP can support genuine agent autonomy over non-trivial application state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zero backend.&lt;/strong&gt; All state lives in React Context and persists to &lt;code&gt;localStorage&lt;/code&gt;. You run &lt;code&gt;npm run dev&lt;/code&gt; and the entire system is live. This is not a limitation -- it is the point. WebMCP's strength is that the agent interacts with application state directly in the browser.&lt;/p&gt;




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

&lt;p&gt;The entire system has four layers. The React app and WebMCP tools run in a single browser tab. The Chrome DevTools MCP server is the bridge that lets Claude reach them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;graph LR
    subgraph "AI Client"
        Claude["Claude Code / Desktop"]
    end

    subgraph "Bridge"
        Server["Chrome DevTools&amp;lt;br/&amp;gt;MCP Server"]
    end

    subgraph "Browser Tab"
        Polyfill["@mcp-b/global&amp;lt;br/&amp;gt;(navigator.modelContext)"]
        Tools["WebMCPTools.jsx&amp;lt;br/&amp;gt;(8 tools via useWebMCP)"]
        Store["React State&amp;lt;br/&amp;gt;(Context + useReducer)"]
        Board["Board / Column / Card&amp;lt;br/&amp;gt;(UI Components)"]
        LS["localStorage"]

        Polyfill --- Tools
        Tools --&amp;gt;|"reads via stateRef"| Store
        Tools --&amp;gt;|"dispatches actions"| Store
        Board --&amp;gt;|"reads"| Store
        Board --&amp;gt;|"dispatches"| Store
        Store &amp;lt;--&amp;gt; LS
    end

    Claude &amp;lt;--&amp;gt;|"stdio&amp;lt;br/&amp;gt;(JSON-RPC)"| Server
    Server &amp;lt;--&amp;gt;|"CDP&amp;lt;br/&amp;gt;(DevTools Protocol)"| Polyfill
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key insight: &lt;code&gt;WebMCPTools.jsx&lt;/code&gt; and the Board component tree are peers. They share the same store. The board renders state for humans; the tools expose state for AI agents. Both read from the same context and dispatch the same reducer actions.&lt;/p&gt;

&lt;h3&gt;
  
  
  The 8 Tools
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_board&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Returns the full board state: all columns, all cards, their positions, and labels.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;create_card&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Creates a new card in a specified column with a title and optional description.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;move_card&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Moves an existing card to a different column by card ID and target column.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;update_card&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Updates a card's title, description, or other mutable properties.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;delete_card&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Removes a card from the board entirely.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;add_label&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Attaches a label (e.g., "bug", "feature", "urgent") to an existing card.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_column_summary&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Returns an AI-friendly summary of all cards in a given column.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prioritize_column&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reorders cards within a column based on priority ranking.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Implementation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  State Management
&lt;/h3&gt;

&lt;p&gt;The store uses React's built-in &lt;code&gt;useReducer&lt;/code&gt; wrapped in a Context provider. No Redux, no Zustand, no external state library.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;initialState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;backlog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;todo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;in-progress&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;done&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;columnMeta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;backlog&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Backlog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gray&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;   &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;todo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;          &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;To Do&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;blue&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;   &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;in-progress&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;In Progress&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;yellow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;done&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;          &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Done&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;green&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;cards&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;card-1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Set up project scaffolding&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Initialize Vite + React + Tailwind&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;column&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;done&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;high&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;setup&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="c1"&gt;// ... 7 more seed cards&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;Cards carry their column as a string field rather than being nested inside column arrays. This makes moves trivial -- update one field instead of splicing between two arrays.&lt;/p&gt;

&lt;p&gt;The reducer handles seven action types:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LOAD_BOARD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Replace entire state (used on hydration from localStorage)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ADD_CARD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Push a new card into the cards array&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MOVE_CARD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Change a card's &lt;code&gt;column&lt;/code&gt; field&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UPDATE_CARD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Merge partial updates into an existing card&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DELETE_CARD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Filter a card out of the array by ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ADD_LABEL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Append a label string to a card's labels array&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;REORDER_COLUMN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Replace the ordering of cards within a column&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every state change persists automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;BoardProvider&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useReducer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;boardReducer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;initialState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;kanban-board&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;kanban-board&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;LOAD_BOARD&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;BoardContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Provider&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dispatch&lt;/span&gt; &lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/BoardContext.Provider&lt;/span&gt;&lt;span class="err"&gt;&amp;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;h3&gt;
  
  
  The stateRef Pattern
&lt;/h3&gt;

&lt;p&gt;This is the most important pattern in the codebase. &lt;code&gt;useWebMCP&lt;/code&gt; registers tool handlers as closures. React closures capture variables from the render in which they were created. If a handler references &lt;code&gt;state&lt;/code&gt; directly, it captures whatever &lt;code&gt;state&lt;/code&gt; was when the hook last ran -- not the current state at the moment the AI agent calls the tool.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// BROKEN: state is captured once and never updates&lt;/span&gt;
&lt;span class="nf"&gt;useWebMCP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;get_board&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({}),&lt;/span&gt;
  &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="c1"&gt;// stale! frozen at registration time&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 fix is a &lt;code&gt;useRef&lt;/code&gt; that always points to the latest state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stateRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;stateRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="c1"&gt;// CORRECT: stateRef.current is always fresh&lt;/span&gt;
&lt;span class="nf"&gt;useWebMCP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;get_board&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({}),&lt;/span&gt;
  &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;stateRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&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;Three lines. The ref object is stable across renders. The effect updates &lt;code&gt;.current&lt;/code&gt; after every state change. Tool handlers always get the latest state regardless of when the closure was created.&lt;/p&gt;

&lt;p&gt;This pattern applies any time you pass callbacks to a registration-style API from within React: WebMCP tools, WebSocket handlers, event bus subscriptions -- anywhere the callback outlives the render that created it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tool Registration
&lt;/h3&gt;

&lt;p&gt;Each tool maps to either a read operation or exactly one reducer action.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;graph LR
    subgraph Tools["WebMCP Tools"]
        T1["get_board"]
        T2["create_card"]
        T3["move_card"]
        T4["update_card"]
        T5["delete_card"]
        T6["add_label"]
        T7["get_column_summary"]
        T8["prioritize_column"]
    end

    subgraph Actions["Reducer Actions"]
        A1["(read only)"]
        A2["ADD_CARD"]
        A3["MOVE_CARD"]
        A4["UPDATE_CARD"]
        A5["DELETE_CARD"]
        A6["ADD_LABEL"]
        A7["(read only)"]
        A8["REORDER_COLUMN"]
    end

    T1 --&amp;gt; A1
    T2 --&amp;gt; A2
    T3 --&amp;gt; A3
    T4 --&amp;gt; A4
    T5 --&amp;gt; A5
    T6 --&amp;gt; A6
    T7 --&amp;gt; A7
    T8 --&amp;gt; A8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is &lt;code&gt;create_card&lt;/code&gt; in full:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;useWebMCP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;create_card&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Create a new card on the kanban board&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;The title of the card&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;A longer description of the card's content&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;column&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;backlog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;todo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;in-progress&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;done&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;backlog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Which column to place the card in&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;low&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;medium&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;high&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;critical&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;medium&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Priority level of the card&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;([])&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Tags or labels to attach to the card&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;column&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;labels&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`card-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
    &lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ADD_CARD&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;column&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;labels&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Card "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" created in &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;column&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="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;Zod &lt;code&gt;.describe()&lt;/code&gt; annotations are the AI's documentation -- those strings are what Claude reads to understand each parameter. &lt;code&gt;.optional().default()&lt;/code&gt; provides safe defaults so the agent can call &lt;code&gt;create_card({ title: "Fix login bug" })&lt;/code&gt; without specifying every field.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;prioritize_column&lt;/code&gt;, which reads state, performs logic, then dispatches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;useWebMCP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;prioritize_column&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Sort all cards in a column by priority (critical &amp;gt; high &amp;gt; medium &amp;gt; low)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;column&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;backlog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;todo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;in-progress&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;done&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;The column to sort by priority&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;column&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stateRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;columnCards&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cards&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="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;column&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;column&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;priorityOrder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;critical&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;high&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;medium&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="na"&gt;low&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sorted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;columnCards&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;priorityOrder&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;priorityOrder&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;REORDER_COLUMN&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;column&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;cardIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Sorted &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;columnCards&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; cards in "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;column&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" by priority`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;priority&lt;/span&gt; &lt;span class="p"&gt;})),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where the &lt;code&gt;stateRef&lt;/code&gt; pattern pays off. The handler reads live state, filters to the target column, sorts by priority, and dispatches &lt;code&gt;REORDER_COLUMN&lt;/code&gt;. If it read &lt;code&gt;state&lt;/code&gt; directly, it would sort based on stale data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Drag-and-Drop Without Libraries
&lt;/h3&gt;

&lt;p&gt;The board supports manual drag-and-drop using native HTML5 DnD APIs. No external libraries. The Card component is the drag source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Card&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;card&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;dispatch&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useBoard&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleDragStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataTransfer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text/plain&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataTransfer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;effectAllowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;move&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTarget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;opacity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0.5&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleDragEnd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTarget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;opacity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;
      &lt;span class="na"&gt;draggable&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;
      &lt;span class="na"&gt;onDragStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleDragStart&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;onDragEnd&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleDragEnd&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"bg-white rounded-lg shadow p-3 cursor-grab active:cursor-grabbing"&lt;/span&gt;
    &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h3&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"font-medium text-sm"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h3&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Column component is the drop target:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;columnId&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dispatch&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useBoard&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isDragOver&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setIsDragOver&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleDragOver&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// Required! Without this, drop is not allowed.&lt;/span&gt;
    &lt;span class="nf"&gt;setIsDragOver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleDragLeave&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTarget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;relatedTarget&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setIsDragOver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleDrop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;setIsDragOver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cardId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataTransfer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text/plain&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cardId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;MOVE_CARD&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cardId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;targetColumn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;columnId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&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;code&gt;onDrop&lt;/code&gt; handler dispatches the exact same &lt;code&gt;MOVE_CARD&lt;/code&gt; action that the WebMCP &lt;code&gt;move_card&lt;/code&gt; tool dispatches. Human drag-and-drop and AI tool calls go through the same code path.&lt;/p&gt;




&lt;h2&gt;
  
  
  Connecting Claude via Chrome DevTools MCP
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://docs.mcp-b.ai/packages/chrome-devtools-mcp" rel="noopener noreferrer"&gt;&lt;code&gt;@mcp-b/chrome-devtools-mcp&lt;/code&gt;&lt;/a&gt; package is a fork of Google's Chrome DevTools MCP server that adds WebMCP tool discovery. It connects any MCP-compatible client to the tools registered on your page through Chrome's DevTools Protocol.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setup
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Claude Code:&lt;/strong&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 chrome-devtools npx @mcp-b/chrome-devtools-mcp@latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Claude Desktop&lt;/strong&gt; -- add to &lt;code&gt;claude_desktop_config.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"chrome-devtools"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&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;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@mcp-b/chrome-devtools-mcp@latest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"--channel"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stable"&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;The &lt;code&gt;--channel stable&lt;/code&gt; flag tells the MCP server to use your regular Chrome installation. Without it, the server defaults to Chrome Dev channel, which most people don't have. Use &lt;code&gt;canary&lt;/code&gt; or &lt;code&gt;beta&lt;/code&gt; if that's what you have installed. Restart Claude Desktop after editing the config.&lt;/p&gt;

&lt;h3&gt;
  
  
  The MCP Server Controls Its Own Chrome Window
&lt;/h3&gt;

&lt;p&gt;This is easy to miss and causes confusion: the Chrome DevTools MCP server launches and controls its own Chrome instance. When you ask Claude to navigate to the kanban board, the board opens in the MCP server's Chrome window -- not in your existing browser.&lt;/p&gt;

&lt;p&gt;This means you need to watch the MCP-controlled Chrome window to see cards appear and move in real time. If you're looking at a different Chrome window, you'll see Claude report success but nothing will change on your screen.&lt;/p&gt;

&lt;p&gt;The workflow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start the kanban board (&lt;code&gt;npm run dev&lt;/code&gt; or use the deployed URL)&lt;/li&gt;
&lt;li&gt;Open Claude Desktop (or Claude Code) and tell it to navigate to the board URL&lt;/li&gt;
&lt;li&gt;Watch the Chrome window that the MCP server opened -- that's where the action happens&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first message to Claude should be explicit:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Navigate to &lt;a href="http://localhost:5174" rel="noopener noreferrer"&gt;http://localhost:5174&lt;/a&gt; and list the WebMCP tools"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude calls &lt;code&gt;list_webmcp_tools&lt;/code&gt;, discovers all 8 kanban tools, and you're ready to go.&lt;/p&gt;

&lt;h3&gt;
  
  
  How Discovery Works
&lt;/h3&gt;

&lt;p&gt;The Chrome DevTools MCP server doesn't know about kanban boards. It discovers tools dynamically by querying the page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;list_webmcp_tools&lt;/code&gt;&lt;/strong&gt; runs in the browser via CDP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;modelContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tools&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;call_webmcp_tool&lt;/code&gt;&lt;/strong&gt; invokes a tool by name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;modelContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;callTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The handler registered by &lt;code&gt;useWebMCP&lt;/code&gt; runs in the page context with full access to React state and dispatch. The return value is serialized back through CDP to the MCP server, then to Claude.&lt;/p&gt;

&lt;h3&gt;
  
  
  Agent Session Walkthrough
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sequenceDiagram
    participant User
    participant Claude
    participant Bridge as Chrome DevTools MCP
    participant Board as Kanban Board (browser)

    User-&amp;gt;&amp;gt;Claude: "Read the board, create 3 bug&amp;lt;br/&amp;gt;cards in To Do, then prioritize&amp;lt;br/&amp;gt;the column."

    Claude-&amp;gt;&amp;gt;Bridge: list_webmcp_tools()
    Bridge-&amp;gt;&amp;gt;Board: navigator.modelContext.tools
    Board--&amp;gt;&amp;gt;Bridge: 8 tools with schemas
    Bridge--&amp;gt;&amp;gt;Claude: Tool list

    Claude-&amp;gt;&amp;gt;Bridge: call_webmcp_tool("get_board", {})
    Bridge-&amp;gt;&amp;gt;Board: navigator.modelContext.callTool(...)
    Board--&amp;gt;&amp;gt;Bridge: { columns: [...], totalCards: 8 }
    Bridge--&amp;gt;&amp;gt;Claude: Board state

    Claude-&amp;gt;&amp;gt;Bridge: call_webmcp_tool("create_card",&amp;lt;br/&amp;gt;{ title: "Login timeout", column: "todo",&amp;lt;br/&amp;gt;priority: "high", labels: ["bug"] })
    Bridge-&amp;gt;&amp;gt;Board: dispatch(ADD_CARD)
    Note over Board: Card appears instantly
    Board--&amp;gt;&amp;gt;Bridge: { success: true, card: {...} }
    Bridge--&amp;gt;&amp;gt;Claude: Created

    Note over Claude: ...repeats for 2 more cards...

    Claude-&amp;gt;&amp;gt;Bridge: call_webmcp_tool("prioritize_column",&amp;lt;br/&amp;gt;{ column: "todo" })
    Bridge-&amp;gt;&amp;gt;Board: dispatch(REORDER_COLUMN)
    Note over Board: Cards reorder by priority
    Board--&amp;gt;&amp;gt;Bridge: { success: true, newOrder: [...] }
    Bridge--&amp;gt;&amp;gt;Claude: Prioritized

    Claude-&amp;gt;&amp;gt;User: "Done. Created 3 bug cards in To Do&amp;lt;br/&amp;gt;and sorted by priority (high first)."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user watches the cards appear and reorder on screen as Claude works. There is no delay between the tool call completing and the UI updating -- they are the same event.&lt;/p&gt;

&lt;h3&gt;
  
  
  WebMCP Tools vs. Screenshot-Based Automation
&lt;/h3&gt;

&lt;p&gt;The Chrome DevTools MCP server also includes ~28 browser automation tools: &lt;code&gt;click&lt;/code&gt;, &lt;code&gt;navigate_page&lt;/code&gt;, &lt;code&gt;take_screenshot&lt;/code&gt;, &lt;code&gt;fill&lt;/code&gt;, and more. WebMCP tools bypass all of that.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Screenshot-based&lt;/th&gt;
&lt;th&gt;WebMCP tools&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Input&lt;/td&gt;
&lt;td&gt;Pixel coordinates, CSS selectors&lt;/td&gt;
&lt;td&gt;Structured JSON with typed parameters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output&lt;/td&gt;
&lt;td&gt;Screenshot image (~2,000 tokens)&lt;/td&gt;
&lt;td&gt;JSON result (~50-200 tokens)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reliability&lt;/td&gt;
&lt;td&gt;Fragile (layout changes break selectors)&lt;/td&gt;
&lt;td&gt;Stable (tool API is the contract)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speed&lt;/td&gt;
&lt;td&gt;Screenshot → vision parse → action → screenshot&lt;/td&gt;
&lt;td&gt;Tool call → result (single round trip)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Precision&lt;/td&gt;
&lt;td&gt;"Click the blue button at (342, 187)"&lt;/td&gt;
&lt;td&gt;&lt;code&gt;create_card({ title: "...", column: "todo" })&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  WebMCP vs. Traditional MCP
&lt;/h2&gt;

&lt;p&gt;What would it take to build the same kanban board using a traditional MCP server with stdio transport?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;graph TB
    subgraph Traditional["Traditional MCP Approach"]
        CLAUDE["Claude Desktop"]
        MCP["MCP Server&amp;lt;br/&amp;gt;(Node.js, stdio)"]
        API["Express.js API"]
        DB["SQLite Database"]
        FE["React Frontend"]

        CLAUDE &amp;lt;--&amp;gt;|"stdio&amp;lt;br/&amp;gt;(JSON-RPC)"| MCP
        MCP --&amp;gt;|HTTP| API
        FE --&amp;gt;|HTTP| API
        API --&amp;gt; DB
    end

    subgraph WebMCP["WebMCP Approach"]
        AGENT["Any AI Agent"]
        BROWSER["React App + WebMCP Tools"]
        LS["localStorage"]

        AGENT &amp;lt;--&amp;gt;|"WebMCP&amp;lt;br/&amp;gt;(browser-mediated)"| BROWSER
        BROWSER &amp;lt;--&amp;gt; LS
    end

    style Traditional fill:#fff5f5
    style WebMCP fill:#f0fff4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The traditional approach requires an MCP server (~200 lines of protocol adapter code), a backend API (~300 lines of Express routes, validation, and database queries), a database schema with migrations, CORS configuration, and -- critically -- a sync mechanism to keep the frontend updated when the agent changes data.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Sync Problem
&lt;/h3&gt;

&lt;p&gt;Without a sync mechanism, the agent and the user are looking at different data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Traditional MCP&lt;/strong&gt; -- the agent moves a card, the backend updates the database, but the React frontend still shows the card in its old column. You need polling (up to N seconds of stale UI) or WebSockets (connection management, reconnection logic, message protocol).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WebMCP&lt;/strong&gt; -- the tool handler dispatches an action, React re-renders, and the card moves on screen in the same frame. There is no gap between "the data changed" and "the user sees the change."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sequenceDiagram
    participant Agent as AI Agent
    participant WMCP as WebMCP (browser)
    participant State as React State
    participant UI as Kanban UI

    Agent-&amp;gt;&amp;gt;WMCP: move_card
    WMCP-&amp;gt;&amp;gt;State: dispatch(MOVE_CARD)
    State--&amp;gt;&amp;gt;UI: Re-render (instant)
    Note over UI: Card moves immediately
    WMCP--&amp;gt;&amp;gt;Agent: { success: true }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Side-by-Side Comparison
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Traditional MCP&lt;/th&gt;
&lt;th&gt;WebMCP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Running processes&lt;/td&gt;
&lt;td&gt;3 (frontend, backend, MCP server)&lt;/td&gt;
&lt;td&gt;1 (Vite dev server)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lines of integration code&lt;/td&gt;
&lt;td&gt;~500+&lt;/td&gt;
&lt;td&gt;~200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;SQLite or PostgreSQL&lt;/td&gt;
&lt;td&gt;localStorage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Config files&lt;/td&gt;
&lt;td&gt;2+ (server config, claude_desktop_config)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real-time sync&lt;/td&gt;
&lt;td&gt;Requires WebSocket/polling&lt;/td&gt;
&lt;td&gt;Automatic (shared React state)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent compatibility&lt;/td&gt;
&lt;td&gt;Claude Desktop only (stdio)&lt;/td&gt;
&lt;td&gt;Any WebMCP agent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup time&lt;/td&gt;
&lt;td&gt;15-30 minutes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;npm install &amp;amp;&amp;amp; npm run dev&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  When Traditional MCP Is Still Better
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Multiple users on different machines need to share the same board.&lt;/strong&gt; WebMCP tools operate on browser-local state. If two people need to see the same board, you need a server-side database and a sync protocol.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data must persist beyond a single browser.&lt;/strong&gt; &lt;code&gt;localStorage&lt;/code&gt; is tied to a browser profile on a single machine. A database survives browser resets, machine changes, and OS reinstalls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The AI agent needs to run headlessly without a browser open.&lt;/strong&gt; WebMCP requires a browser tab. If the agent runs in CI, a cron job, or any environment without a display, traditional MCP's stdio transport works anywhere Node.js runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The web UI does not exist.&lt;/strong&gt; If the application is a pure API service with no frontend, there is no browser for WebMCP to run in.&lt;/p&gt;




&lt;h2&gt;
  
  
  MCP as Adapter vs. MCP-Native Apps vs. WebMCP
&lt;/h2&gt;

&lt;p&gt;Beyond where the MCP server runs, there is a second question: what role does MCP play in the architecture?&lt;/p&gt;

&lt;h3&gt;
  
  
  MCP as an Adapter Layer
&lt;/h3&gt;

&lt;p&gt;You have a backend with an API, a frontend with a UI, and the MCP server is a thin translation layer that converts tool calls into API requests. The backend does not know or care whether a request came from a user clicking a button or an LLM invoking a tool.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User clicks "Add Card"  --&amp;gt;  Frontend  --&amp;gt;  POST /api/cards  --&amp;gt;  Backend
LLM calls create_card   --&amp;gt;  MCP Server --&amp;gt;  POST /api/cards  --&amp;gt;  Backend
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The MCP server contains no business logic. It is a protocol adapter: JSON-RPC in, HTTP out. The backend owns validation, persistence, and business rules. The app works without MCP.&lt;/p&gt;

&lt;p&gt;For a concrete example, the &lt;a href="https://github.com/grzetich/ai-developer-tools-mcp" rel="noopener noreferrer"&gt;ai-developer-tools-mcp&lt;/a&gt; project demonstrates this pattern: a ~200-line MCP server wraps an existing REST API for AI developer tool analytics. The API handles authentication, rate limiting, and data queries. The MCP server just translates tool calls into &lt;code&gt;fetch()&lt;/code&gt; requests and formats the JSON responses for Claude. The API has no idea it is being called by an LLM -- and that is the point.&lt;/p&gt;

&lt;p&gt;The structural parallel to this kanban board is exact:&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;ai-developer-tools-mcp&lt;/th&gt;
&lt;th&gt;webmcp-kanban&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;App&lt;/td&gt;
&lt;td&gt;REST API (data platform)&lt;/td&gt;
&lt;td&gt;React app (kanban board)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bridge&lt;/td&gt;
&lt;td&gt;MCP server (~200 lines)&lt;/td&gt;
&lt;td&gt;WebMCPTools.jsx + Chrome DevTools MCP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bridge's job&lt;/td&gt;
&lt;td&gt;Tool calls → HTTP requests&lt;/td&gt;
&lt;td&gt;Tool calls → &lt;code&gt;dispatch()&lt;/code&gt; to React state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App changes needed&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same pattern, different transport. One goes over HTTP to a backend. The other goes directly into browser state.&lt;/p&gt;

&lt;h3&gt;
  
  
  MCP-Native Apps
&lt;/h3&gt;

&lt;p&gt;In an MCP-native app, the MCP server &lt;em&gt;is&lt;/em&gt; the application. There is no separate REST API -- the tools, resources, and prompts defined in MCP are the primary interface. Any UI built on top calls through MCP, not around it.&lt;/p&gt;

&lt;p&gt;This pattern makes sense for tools designed primarily for LLM consumption: code analysis tools, data pipelines, documentation generators, dev tooling. The LLM is the intended user, and a traditional web UI is secondary or nonexistent.&lt;/p&gt;

&lt;h3&gt;
  
  
  Comparison
&lt;/h3&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;MCP as Adapter&lt;/th&gt;
&lt;th&gt;MCP-Native App&lt;/th&gt;
&lt;th&gt;WebMCP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Existing app?&lt;/td&gt;
&lt;td&gt;Drop-in, no refactor needed&lt;/td&gt;
&lt;td&gt;Built from scratch for MCP&lt;/td&gt;
&lt;td&gt;Drop-in for React apps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Primary UI&lt;/td&gt;
&lt;td&gt;Web interface for humans&lt;/td&gt;
&lt;td&gt;LLM is the primary consumer&lt;/td&gt;
&lt;td&gt;Web interface, shared with LLM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Without an LLM&lt;/td&gt;
&lt;td&gt;Fully functional&lt;/td&gt;
&lt;td&gt;Limited or unusable&lt;/td&gt;
&lt;td&gt;Fully functional&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Architecture&lt;/td&gt;
&lt;td&gt;Two layers (API + MCP)&lt;/td&gt;
&lt;td&gt;Single layer (MCP is the API)&lt;/td&gt;
&lt;td&gt;Single layer (browser is the runtime)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Complexity&lt;/td&gt;
&lt;td&gt;More moving parts, but decoupled&lt;/td&gt;
&lt;td&gt;Simpler, but coupled to MCP&lt;/td&gt;
&lt;td&gt;Simplest for browser-based apps&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  A Retrofit Path for Existing React Apps
&lt;/h2&gt;

&lt;p&gt;Everything described in this post was built alongside the kanban board. But it didn't have to be.&lt;/p&gt;

&lt;p&gt;The kanban board's WebMCP integration lives in a single file: &lt;code&gt;WebMCPTools.jsx&lt;/code&gt;. That file reads state through &lt;code&gt;useBoardState()&lt;/code&gt; and writes state through &lt;code&gt;dispatch()&lt;/code&gt; -- the same hooks the UI components use. It does not modify the board's reducer, its components, or its persistence logic. If you deleted &lt;code&gt;WebMCPTools.jsx&lt;/code&gt;, the kanban board would still work exactly as before. If you added it back, the board would become AI-accessible again. The app itself never changes.&lt;/p&gt;

&lt;p&gt;This pattern generalizes to any React application that manages state through hooks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If your app uses Context + useReducer&lt;/strong&gt;, you write tool handlers that call &lt;code&gt;dispatch()&lt;/code&gt; with the same action types your components already use. The reducer doesn't know or care that the action came from an AI agent instead of a button click.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If your app uses Redux&lt;/strong&gt;, the tool handlers call &lt;code&gt;store.dispatch()&lt;/code&gt;. The reducers, middleware, and selectors all work unchanged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If your app uses Zustand, Jotai, or any hook-based store&lt;/strong&gt;, the tool handlers call the same setter functions your components call.&lt;/p&gt;

&lt;p&gt;The integration is always the same shape: a new file that imports the existing store, registers tools against &lt;code&gt;navigator.modelContext&lt;/code&gt;, and maps each tool to a read or write operation the app already supports. No refactoring. No new API layer. No changes to existing components.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;graph TD
    subgraph "Existing React App (unchanged)"
        Store["State Management&amp;lt;br/&amp;gt;(Context, Redux, Zustand, etc.)"]
        UI["UI Components"]
        UI &amp;lt;--&amp;gt;|"existing hooks"| Store
    end

    subgraph "New File"
        Tools["WebMCPTools.jsx"]
    end

    Tools --&amp;gt;|"same hooks"| Store
    Agent["AI Agent"] &amp;lt;--&amp;gt;|"navigator.modelContext"| Tools
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For teams with large, established React applications, this is the key takeaway: WebMCP is not a rewrite. It is one file that gives an LLM the same access your components already have.&lt;/p&gt;




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

&lt;p&gt;The kanban board is a small, familiar application -- but it exercises every property that makes WebMCP interesting: shared context, instant feedback, composable tools, and zero-infrastructure agent integration.&lt;/p&gt;

&lt;p&gt;The entire app is roughly 500 lines of code across five files. Each WebMCP tool either reads state or dispatches exactly one reducer action. The AI agent composes them into sequences -- "get the board, create three cards, move the bug to in-progress, prioritize the todo column" -- and each call is an atomic operation against the reducer.&lt;/p&gt;

&lt;p&gt;For a single-user tool where the agent and user share the same context, WebMCP eliminates an enormous amount of accidental complexity. The traditional approach turns a 200-line integration into a 500+ line multi-process system with a sync problem to solve. None of those components are difficult to build. But all of them are unnecessary when the browser is the runtime.&lt;/p&gt;

</description>
      <category>webmcp</category>
      <category>mcp</category>
      <category>ai</category>
      <category>react</category>
    </item>
    <item>
      <title>How Much Do Your Docs Cost to Read?</title>
      <dc:creator>Ed G</dc:creator>
      <pubDate>Thu, 12 Feb 2026 03:14:11 +0000</pubDate>
      <link>https://forem.com/writerviber/how-much-do-your-docs-cost-to-read-h56</link>
      <guid>https://forem.com/writerviber/how-much-do-your-docs-cost-to-read-h56</guid>
      <description>&lt;p&gt;I’ve been spending a lot of time thinking about how AI tools consume documentation. Not how developers read docs, but how Copilot, Cursor, and AI agents ingest them as tokens.&lt;/p&gt;

&lt;p&gt;One thing that keeps coming to mind: nobody talks about what that actually costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tool
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://doc-co%20st.vercel.app/" rel="noopener noreferrer"&gt;docs-cost-calculator&lt;/a&gt; lets you paste any structured documentation and see how many tokens it takes to represent it across different formats, like JSON, YAML, JSON Compact, Plain Text, side by side, with cost estimates at scale.&lt;/p&gt;

&lt;p&gt;It runs entirely in the browser using cl100k_base tokenization (the encoding used by GPT-4 and Claude). No AI, no API keys needed, and nothing leaves your machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I built it
&lt;/h2&gt;

&lt;p&gt;I was doing research on documentation formats for a project and got tired of running tokenization scripts manually every time I wanted to compare two versions of the same docs. So I built a quick UI for it.&lt;/p&gt;

&lt;p&gt;Once I had it working, the language comparison feature came out of curiosity. Byte Pair Encoding (BPE) tokenizers were trained heavily on English text, so I wondered what happens when you tokenize the same structured content in other languages. The results were interesting enough to include.&lt;/p&gt;

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

&lt;p&gt;Paste your own OpenAPI specs, config objects, or error references and see what comes back: &lt;a href="https://doc-cost.vercel.app/" rel="noopener noreferrer"&gt;doc-cost.vercel.app&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you’re working on documentation that gets consumed by AI tools at any kind of scale, the numbers might surprise you.&lt;/p&gt;

&lt;p&gt;I’m writing more about this topic. Stay tuned.&lt;/p&gt;

</description>
      <category>api</category>
      <category>documentation</category>
      <category>ai</category>
    </item>
    <item>
      <title>Claude Context Analysis: What 500 CLAUDE.md Files Reveal About How Developers Use Claude</title>
      <dc:creator>Ed G</dc:creator>
      <pubDate>Sat, 07 Feb 2026 17:20:08 +0000</pubDate>
      <link>https://forem.com/writerviber/claude-context-analysis-what-500-claudemd-files-reveal-about-how-developers-use-claude-4e9m</link>
      <guid>https://forem.com/writerviber/claude-context-analysis-what-500-claudemd-files-reveal-about-how-developers-use-claude-4e9m</guid>
      <description>&lt;p&gt;Every day at 3 AM GMT, a Flask app I built wakes up, scrapes 500 CLAUDE.md files from public GitHub repositories, runs topic modeling on them, and publishes the results. The goal: figure out what developers are actually putting in their CLAUDE.md files and how those patterns change over time.&lt;/p&gt;

&lt;p&gt;Check it out: &lt;a href="https://analyze-claude-md.onrender.com" rel="noopener noreferrer"&gt;analyze-claude-md.onrender.com&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this exists
&lt;/h2&gt;

&lt;p&gt;CLAUDE.md files are how developers tell Claude Code how to work with their project. They contain coding standards, build commands, architecture decisions, and behavioral instructions. But there’s no official guidance on what makes a good one. Everyone’s figuring it out independently.&lt;/p&gt;

&lt;p&gt;I wanted to answer a simple question: what are the common patterns? If 500 repos all mention “typescript” and “npm” in their CLAUDE.md files, that tells you something about the ecosystem. If “agent” is trending upward over 30 days, that tells you something about where things are headed.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;The pipeline has four stages:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Collection: The GitHub Code Search API finds files named claude.md across public repos. The app downloads up to 500 per run, handling pagination and rate limits.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Preprocessing: NLTK tokenizes the text, removes stopwords, and lemmatizes words down to root forms. “Running” becomes “run,” “configurations” becomes “configuration.”&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Topic modeling: NMF (Non-negative Matrix Factorization) discovers 5 topics, each a weighted distribution over words. The model uses TF-IDF (Term Frequency-Inverse Document Frequency) weighting, which downweights common terms and highlights distinctive ones.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Visualization: Results are saved to JSON and rendered as horizontal bar charts on the homepage, with word weights shown proportionally.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The whole thing runs on Render’s free tier with no database. Analysis history is stored as JSON files committed to the repo so data survives redeploys.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing NMF over LDA
&lt;/h2&gt;

&lt;p&gt;The project originally used LDA (Latent Dirichlet Allocation), which is the textbook approach for topic modeling. It was brittle for this use case.&lt;/p&gt;

&lt;p&gt;LDA topics shifted significantly between runs, even with a fixed random seed, because the input data changes daily (GitHub returns different repos each time). The topics were often incoherent, mixing unrelated terms. And because LDA uses raw word counts, common words dominated even when they weren’t distinctive.&lt;/p&gt;

&lt;p&gt;NMF paired with TF-IDF (Term Frequency-Inverse Document Frequency) solved all three problems. Topics are more coherent (the top words within each topic clearly relate to each other), more stable across runs, and better at surfacing distinctive terms rather than just frequent ones. The swap was nearly drop-in since both models are in scikit-learn with the same API.&lt;/p&gt;

&lt;p&gt;BERTopic would produce even better results using transformer embeddings for semantic understanding, but it requires 80-400MB for the sentence-transformers model, which won’t fit on Render’s free tier.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the data shows
&lt;/h2&gt;

&lt;p&gt;After 30 daily runs, some clear patterns emerge:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;“npm” is the most consistently dominant term, appearing with high weight in nearly every run. Frontend tooling dominates CLAUDE.md files.&lt;/li&gt;
&lt;li&gt;“typescript,” “react,” and “server” form a stable cluster. The JavaScript/TypeScript ecosystem is where most Claude Code usage lives.&lt;/li&gt;
&lt;li&gt;“agent” has been trending upward over the past month, reflecting the growing use of agentic patterns.&lt;/li&gt;
&lt;li&gt;“python,” “database,” and “api” are consistently present but with more variance, suggesting they’re concentrated in specific project types rather than being universal.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the &lt;a href="https://analyze-claude-md.onrender.com/trends" rel="noopener noreferrer"&gt;term trends page&lt;/a&gt;, you can toggle individual terms on and off, with both absolute weight and normalized (0-100%) views for comparing terms with different baselines.&lt;/p&gt;

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

&lt;p&gt;One interesting challenge: how do you name a topic? LDA and NMF produce distributions over words, not labels. The current approach is a heuristic function that checks if the top 5 words contain keywords from hardcoded lists:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;python&lt;br&gt;
if any(word in top_5_words for word in ['npm', 'typescript', 'react']):&lt;br&gt;
    return "Frontend Development"&lt;br&gt;
&lt;/code&gt;&lt;br&gt;
This is the weakest part of the system. If “react” happens to be the 6th word instead of top 5, a clearly frontend topic gets a generic fallback label. It’s why the term trends page tracks individual words rather than relying on topic labels. The words are the stable data; the labels are cosmetic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Technical details
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Stack: Flask, scikit-learn, NLTK, Chart.js, Jinja2&lt;/li&gt;
&lt;li&gt;Hosting: Render free tier (512MB RAM, cold starts)&lt;/li&gt;
&lt;li&gt;Storage: JSON files committed to the repo&lt;/li&gt;
&lt;li&gt;Scheduling: Background daemon thread checks hourly, runs analysis at 3 AM GMT&lt;/li&gt;
&lt;li&gt;Data: 500 files per run, 30-day rolling history, 15 terms tracked in trends&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The app has no database. Historical data is an array of JSON objects appended to a file. Topic evolution tracking uses cosine similarity to match topics across runs despite label changes. The whole codebase is a single app.py file with Jinja2 templates. No user data is stored. All analysis is performed in memory on publicly available content. Temporary files are deleted after processing.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;NMF is underrated&lt;/strong&gt;. Most topic modeling tutorials teach LDA first, but for short documents with noisy input, NMF with TF-IDF produces noticeably better results with less tuning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Track words, not topics&lt;/strong&gt;. Topic-level tracking across runs is fragile because the groupings shift. Term-level tracking is rock solid and often more useful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Commit your data&lt;/strong&gt;. Storing analysis history as committed JSON files instead of relying on ephemeral server storage was a simple decision that saved the project’s 30-day history from being wiped on every deploy.&lt;/p&gt;

&lt;p&gt;Check it out: &lt;a href="https://analyze-claude-md.onrender.com" rel="noopener noreferrer"&gt;analyze-claude-md.onrender.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Source code: &lt;a href="https://github.com/grzetich/analyzeclaudemd" rel="noopener noreferrer"&gt;github.com/grzetich/analyzeclaudemd&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>datascience</category>
      <category>contextengineering</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Making data conversational: Building MCP Servers as API bridges</title>
      <dc:creator>Ed G</dc:creator>
      <pubDate>Mon, 12 Jan 2026 00:34:33 +0000</pubDate>
      <link>https://forem.com/writerviber/making-data-conversational-building-mcp-servers-as-api-bridges-2onb</link>
      <guid>https://forem.com/writerviber/making-data-conversational-building-mcp-servers-as-api-bridges-2onb</guid>
      <description>&lt;p&gt;At the &lt;a href="https://www.fortwayne-ai.com/" rel="noopener noreferrer"&gt;Fort Wayne AI&lt;/a&gt; meetup on 09 January, I presented about a pattern I've discovered while building &lt;a href="https://vibe-data.com" rel="noopener noreferrer"&gt;Vibe Data&lt;/a&gt;: Making data conversational by using a model context protocol (MCP) server on top of existing REST APIs. Your API provide your data, an MCP server provides access to the API for a desktop LLM client like Claude or ChatGPT, and the LLM client provides conversational access to your data.&lt;/p&gt;

&lt;p&gt;This post captures what I learned building this architecture and what I shared with the developer community.&lt;/p&gt;

&lt;h2&gt;
  
  
  The situation: Two audiences, one backend
&lt;/h2&gt;

&lt;p&gt;When you're building a data platform, you inevitably face a challenge: different users need different interfaces.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Developers want:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Programmatic API access&lt;/li&gt;
&lt;li&gt;JSON they can transform&lt;/li&gt;
&lt;li&gt;Full control for automation&lt;/li&gt;
&lt;li&gt;Integration with their tools&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;End users want:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Quick answers to questions&lt;/li&gt;
&lt;li&gt;No coding required&lt;/li&gt;
&lt;li&gt;Natural language queries&lt;/li&gt;
&lt;li&gt;Simple, intuitive interfaces&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The traditional approach is to build two separate systems: a REST API for developers and dashboards or reports for end users. But this creates maintenance overhead, duplicate business logic, and architectural complexity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution: MCP as an API bridge
&lt;/h2&gt;

&lt;p&gt;I've found a better pattern: &lt;strong&gt;build your REST API first, then add an MCP server as a thin conversational wrapper.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's the architecture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;          Data platform
                 │
            REST API
    (Business Logic | Auth | Rate Limiting)
                 │
        ┌────────┴────────┐
        │                 │
   Direct API        MCP Server
    Access            (~200 lines)
        │                 │
   Developers      Users + Claude
   (JSON/Code)     (Conversation)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The REST API remains your single source of truth. All business logic, authentication, rate limiting, and caching live here. The MCP server is just a formatting layer that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Receives structured tool calls from Claude.&lt;/li&gt;
&lt;li&gt;Translates them to API requests.&lt;/li&gt;
&lt;li&gt;Formats JSON responses as natural language.&lt;/li&gt;
&lt;li&gt;Returns conversational text.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why this pattern works
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Separation of concerns
&lt;/h3&gt;

&lt;p&gt;Your API handles the hard stuff:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Database queries&lt;/li&gt;
&lt;li&gt;Authentication and authorization&lt;/li&gt;
&lt;li&gt;Rate limiting and caching&lt;/li&gt;
&lt;li&gt;Business logic and validation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your MCP server handles presentation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Formatting JSON as readable text&lt;/li&gt;
&lt;li&gt;Adding context and insights&lt;/li&gt;
&lt;li&gt;Transforming data into conversation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When your API changes, both interfaces get the update automatically. No duplicate logic to maintain.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Opportunity for two products from one source
&lt;/h3&gt;

&lt;p&gt;You can serve both audiences without building twice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;API tier&lt;/strong&gt;: Developers get JSON, higher rate limits, programmatic access&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conversational tier&lt;/strong&gt;: End users get Claude access, simpler pricing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same backend. Different value propositions.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Progressive enhancement
&lt;/h3&gt;

&lt;p&gt;You're not choosing between API or MCP. You're adding conversational access to an existing system:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Start with API (proven, understood, lots of tooling)&lt;/li&gt;
&lt;li&gt;Add MCP layer when ready (thin wrapper, low risk)&lt;/li&gt;
&lt;li&gt;Keep both interfaces running (serve more users)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I learned building this
&lt;/h2&gt;

&lt;h3&gt;
  
  
  MCP works best as a bridge
&lt;/h3&gt;

&lt;p&gt;Don't try to rebuild your entire backend in MCP. Don't put business logic in your MCP tools. Build a solid REST API first. That's your product. The MCP server should be ~200 lines of code that calls your API and formats responses.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;async getToolMetrics(toolId) {
    try {
      const response = await fetch(
        ${this.baseURL}/tools/${toolId}/metrics
...
const toolName = data[0].name || toolId;
let output = `📈 ${toolName.toUpperCase()} - ${months} Month History\n\n`;
  output += `**Download Trend:**\n`;
....

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Different interfaces for different users
&lt;/h3&gt;

&lt;p&gt;Developers want JSON they can transform however they need. They'll build custom dashboards, automate reports, integrate with other systems. Give them an API.&lt;/p&gt;

&lt;p&gt;End users just want answers. They don't want to learn &lt;code&gt;curl&lt;/code&gt; commands or read API documentation. They want to ask "What's Cursor's growth trend?" and get an answer. Give them Claude with MCP.&lt;/p&gt;

&lt;p&gt;You can serve both without building duplicate systems.&lt;/p&gt;

&lt;h3&gt;
  
  
  The formatting layer is where MCP adds value
&lt;/h3&gt;

&lt;p&gt;Your API returns data:&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="nl"&gt;"downloads"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8100000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"growth_pct"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;55.8&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;Your MCP server transforms it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cursor grew 55% over the quarter, reaching 8.1M monthly 
downloads, indicating strong developer adoption.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same data. One is optimized for machines. One is optimized for humans.&lt;/p&gt;

&lt;p&gt;This formatting layer, turning structured data into meaningful insights, is where conversational interfaces shine. It's not just passing through JSON; it's contextualizing and explaining it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Demo: Making data conversational
&lt;/h2&gt;

&lt;p&gt;During the presentation, I demonstrated this with Vibe Data's adoption intelligence and Claude desktop:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Query 1:&lt;/strong&gt; "What's Cursor's adoption trend over the last quarter?"&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Claude calls &lt;code&gt;get_tool_history&lt;/code&gt; tool&lt;/li&gt;
&lt;li&gt;MCP server calls REST API&lt;/li&gt;
&lt;li&gt;Returns formatted trend analysis with growth calculations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Query 2:&lt;/strong&gt; "How does Cursor compare to GitHub Copilot?"&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Claude calls &lt;code&gt;compare_tools&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Gets metrics for both&lt;/li&gt;
&lt;li&gt;Synthesizes side-by-side comparison with key insights&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Query 3:&lt;/strong&gt; "Which AI coding tools are growing fastest right now?"&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Claude calls &lt;code&gt;get_trending_tools&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Ranks by growth percentage&lt;/li&gt;
&lt;li&gt;Presents as ordered list with context&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern works because Claude can compose these tools in ways I didn't pre-build. Ask "compare the top 3 trending tools" and Claude chains multiple calls automatically. A report would need that query to be pre-built and a dashboard might require the user to pick the appropriate options from menus.&lt;/p&gt;

&lt;h2&gt;
  
  
  Being honest about limitations
&lt;/h2&gt;

&lt;p&gt;MCP isn't magic, and I told the audience that. Current challenges:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Discovery:&lt;/strong&gt; Users don't automatically know what tools are available. They have to ask or explore. The ecosystem needs better tool discovery UIs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Distribution:&lt;/strong&gt; When you add new tools, users need to update locally. Cloud-hosted MCP servers would solve this with instant updates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anticipation:&lt;/strong&gt; You still need to build specific tools for specific questions. MCP doesn't eliminate the need to think about what users need.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;But even with these limitations, it's better:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Natural language beats clicking through filters.&lt;/li&gt;
&lt;li&gt;Claude can compose tools dynamically.&lt;/li&gt;
&lt;li&gt;Graceful degradation ("I don't have Reddit data") beats silent missing features or cryptic error codes.&lt;/li&gt;
&lt;li&gt;Standardized protocol beats reinventing the wheel.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Understanding both strengths and friction points, not just evangelizing uncritically, helps drive real adoption.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-world use cases
&lt;/h2&gt;

&lt;p&gt;This pattern works for any product with data exposed by APIs:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;B2B SaaS:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;API → Analytics platforms, customer dashboards&lt;/li&gt;
&lt;li&gt;MCP → "How's our MRR trending?" "Which customers churned?"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;E-commerce:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;API → Inventory systems, order management&lt;/li&gt;
&lt;li&gt;MCP → "What products are low stock?" "Show me returns this week"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Internal Tools:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;API → Automated reports, integrations&lt;/li&gt;
&lt;li&gt;MCP → "Find pending invoices" "Compare Q3 vs Q4 sales"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern is universal: build API first, wrap with MCP, serve both technical and non-technical users.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code: Open source educational implementation
&lt;/h2&gt;

&lt;p&gt;I've open-sourced an educational MCP server that demonstrates this pattern: &lt;a href="https://github.com/grzetich/ai-developer-tools-mcp" rel="noopener noreferrer"&gt;github.com/grzetich/ai-developer-tools-mcp&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It uses sample data to show the architecture without requiring database access. The structure is identical to production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/
├── tools/          # MCP tool definitions
├── api/            # API client (THE BRIDGE)
├── data/           # Mock data (simulates database)
└── utils/          # Response formatters
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In production, only &lt;code&gt;api/client.js&lt;/code&gt; changes - from mock data to real &lt;code&gt;fetch()&lt;/code&gt; calls. Everything else stays the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;I'm continuing to refine this pattern at Vibe Data, where the MCP server serves production traffic alongside our REST API. The dual-interface approach lets us serve both developers building integrations and investors asking questions.&lt;/p&gt;

&lt;p&gt;I'm also exploring how to solve the distribution and discovery challenges, potentially through cloud-hosted MCP servers that auto-update when new tools are deployed like my &lt;a href="https://github.com/grzetich/pokemon-mcp" rel="noopener noreferrer"&gt;Pokémon MCP server&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you're building with MCP or thinking about conversational interfaces for your data, I'd love to hear what patterns you're discovering. Reach out on &lt;a href="https://github.com/grzetich" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; or &lt;a href="mailto:ed.grzetich@gmail.com"&gt;email&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Educational MCP Server:&lt;/strong&gt; &lt;a href="https://github.com/grzetich/ai-developer-tools-mcp" rel="noopener noreferrer"&gt;github.com/grzetich/ai-developer-tools-mcp&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Presentation Slides:&lt;/strong&gt; &lt;a href="https://speakerdeck.com/egrzetich/making-data-conversational-building-mcp-servers-as-api-bridges" rel="noopener noreferrer"&gt;View on SpeakerDeck&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCP Documentation:&lt;/strong&gt; &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;modelcontextprotocol.io&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>mcp</category>
      <category>api</category>
      <category>ai</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
