<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Shrijal Acharya</title>
    <description>The latest articles on DEV Community by Shrijal Acharya (@shricodev).</description>
    <link>https://dev.to/shricodev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1127015%2F1c5e48a2-f602-4e7d-8312-3c0322d155c6.jpg</url>
      <title>DEV Community: Shrijal Acharya</title>
      <link>https://dev.to/shricodev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/shricodev"/>
    <language>en</language>
    <item>
      <title>How to Moderate Live Chat in Real Time with Jev and Composio (Discord + Twitch)</title>
      <dc:creator>Shrijal Acharya</dc:creator>
      <pubDate>Wed, 30 Sep 2026 13:05:00 +0000</pubDate>
      <link>https://dev.to/composiodev/how-to-moderate-live-chat-in-real-time-with-jev-and-composio-discord-twitch-5ab0</link>
      <guid>https://dev.to/composiodev/how-to-moderate-live-chat-in-real-time-with-jev-and-composio-discord-twitch-5ab0</guid>
      <description>&lt;p&gt;Anyone who has moderated a live chat knows how fast things go wrong.&lt;/p&gt;

&lt;p&gt;Someone says something bad and by the time a mod sees it, everyone in the chat already has. 🫩&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/yYpI41NO_74" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Most AI moderation setups use an LLM for this. That works, but an LLM is usually doing way more than we need here and is way slower.&lt;/p&gt;

&lt;p&gt;For moderation, I mostly want one thing: &lt;strong&gt;should I remove this message or not?&lt;/strong&gt; So I tried &lt;strong&gt;Jev&lt;/strong&gt;, a decision model from TypeSafe. I guess you're already familiar with the model, as its been over a week the model dropped.&lt;/p&gt;

&lt;p&gt;It does not generate text. You give it a typed question, and it gives you a probability back.&lt;/p&gt;

&lt;p&gt;Thats pretty much perfect for a moderation bot. 👀&lt;/p&gt;

&lt;p&gt;I called Jev through &lt;a href="https://composio.dev/toolkits/jev" rel="noopener noreferrer"&gt;Composio's Jev toolkit&lt;/a&gt;, so I didn't have to set up a separate HTTP client or keep the Jev API key inside the code.&lt;/p&gt;

&lt;p&gt;Then I connected the same moderation logic to &lt;strong&gt;Discord&lt;/strong&gt; and &lt;strong&gt;Twitch&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you want to play around with the project, or build on top of it, you can find the source code here:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Source Code: &lt;a href="https://github.com/shricodev/jev-youtube-discord-moderator" rel="noopener noreferrer"&gt;shricodev/jev-youtube-discord-moderator&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Let's get into it. ✌️&lt;/p&gt;

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

&lt;p&gt;Here's what we'll go through:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What Jev is and how it is different from a normal LLM (in short)&lt;/li&gt;
&lt;li&gt;Setting up Jev through Composio&lt;/li&gt;
&lt;li&gt;Building a Discord bot that removes such messages&lt;/li&gt;
&lt;li&gt;Building the same moderation flow for Twitch with EventSub&lt;/li&gt;
&lt;li&gt;A quick YouTube version (I leave that upto you)😉&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;NOTE:&lt;/strong&gt; This is still an AI bot, so you might want to keep human mods around. But considering how cheap it is, at &lt;strong&gt;$0.042 per million input tokens&lt;/strong&gt; with output basically free, it could be worth trying if you're a streamer or run a large live chat.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What's Jev, Anyway?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyoss0vtpci8vgbnnh97f.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyoss0vtpci8vgbnnh97f.webp" alt="Jev AI Model" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The easiest way I think about Jev is this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An LLM generates an answer. Jev makes a decision.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You send Jev a &lt;code&gt;state&lt;/code&gt;, which for us is the chat message, along with one or more questions. It can answer all of those questions in the same call.&lt;/p&gt;

&lt;p&gt;Jev currently gives three question types:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Noul&lt;/strong&gt; - Ask whether something is true and get a probability between 0 and 1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choice&lt;/strong&gt; - Give it a few options and let it pick one, along with probabilities.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Score&lt;/strong&gt; - Ask it to place something somewhere on an ordered scale of 2 to 10 options.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For moderation, &lt;strong&gt;Noul&lt;/strong&gt; is the important one.&lt;/p&gt;

&lt;p&gt;You can ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Is this a hostile comment at a person?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And instead of getting a paragraph back, you get something like:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Now I can simply decide what to do with that number.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;p ≥ 0.8&lt;/code&gt;, delete it.&lt;/p&gt;

&lt;p&gt;If it sits between &lt;code&gt;0.5&lt;/code&gt; and &lt;code&gt;0.8&lt;/code&gt;, send it for review. Everything else stays.&lt;/p&gt;

&lt;p&gt;No parsing generated text. No asking the model to "only answer YES or NO." 🤌&lt;/p&gt;

&lt;p&gt;It's also super cheap ($42 per billion input token, &lt;strong&gt;BILLION!!&lt;/strong&gt;, output is free).&lt;/p&gt;

&lt;p&gt;To give you an approximate, moderating 1,000 messages (roughly counting ~400 input token per msg) comes out to &lt;strong&gt;less than 2 cents&lt;/strong&gt;, almost free.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We're Building
&lt;/h2&gt;

&lt;p&gt;We are building two bots, but both use the same moderation logic.&lt;/p&gt;

&lt;p&gt;The flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;new chat message
   │
   ▼
Jev via Composio (JEV_EVALUATE_STATE)
   ├── negative: noul   → "is this a hostile comment at a person?"
   └── category: choice → insult / harassment / spam / fine
   │
   ▼
p ≥ 0.8  → delete the message
p ≥ 0.5  → log it for review
else     → keep it
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The nice part is that the Jev code stays exactly the same on both platforms.&lt;/p&gt;

&lt;p&gt;Only the part that receives and deletes messages changes.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Discord:&lt;/strong&gt; &lt;code&gt;discord.js&lt;/code&gt; listens for messages and deletes them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Twitch:&lt;/strong&gt; EventSub sends us messages over WebSocket, and the Helix API deletes them.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💁 I've never used Twitch, so I'm not sure how good this works in a long term compared to Discord.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We also need a way to properly test the bots.&lt;/p&gt;

&lt;p&gt;So I added a small &lt;strong&gt;flooder&lt;/strong&gt; script that sends a bunch of normal/racist messages into the test chat.&lt;/p&gt;

&lt;p&gt;We'll use that later.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Why not use Composio for Discord too?&lt;/strong&gt; Composio's Discord toolkit does not currently have a real-time new-message trigger. Polling would add extra delay, so I use the native SDK for incoming chat and Composio for Jev.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Tech Stack
&lt;/h2&gt;

&lt;p&gt;The project is pretty small.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Jev (TypeSafe)&lt;/strong&gt; - Decides whether the message is hostile and gives us a probability.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Composio&lt;/strong&gt; - Handles the Jev connection and gives us one &lt;code&gt;tools.execute()&lt;/code&gt; call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;discord.js&lt;/strong&gt; - Listens for and deletes Discord messages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Twitch EventSub + Helix&lt;/strong&gt; - Receives Twitch chat messages and deletes them when needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Node.js 22&lt;/strong&gt; - Everything runs as plain &lt;code&gt;.mjs&lt;/code&gt; files, so there is no framework or build step (perfect to test right away).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Node 22 already includes &lt;code&gt;fetch&lt;/code&gt; and &lt;code&gt;WebSocket&lt;/code&gt;, which also keeps the Twitch side pretty clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Set Up the Project
&lt;/h2&gt;

&lt;p&gt;Getting the project running is pretty simple.&lt;/p&gt;

&lt;p&gt;Clone the repo and install the dependencies:&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/shricodev/jev-youtube-discord-moderator
&lt;span class="nb"&gt;cd &lt;/span&gt;jev-youtube-discord-moderator/jev-discord-demo
npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Configure Composio
&lt;/h3&gt;

&lt;p&gt;First, you need a Composio API key.&lt;/p&gt;

&lt;p&gt;Create an account on &lt;a href="https://dashboard.composio.dev/" rel="noopener noreferrer"&gt;Composio&lt;/a&gt;, get your API key, and add it to the &lt;code&gt;.env&lt;/code&gt; file in the root of the project.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpasnank598slz8gn7ds6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpasnank598slz8gn7ds6.png" alt="composio create api key" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then add the &lt;a href="https://composio.dev/toolkits/jev" rel="noopener noreferrer"&gt;Jev toolkit&lt;/a&gt; from the dashboard and connect it using your TypeSafe API key.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdi86aqzvxfwo94nie7vt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdi86aqzvxfwo94nie7vt.png" alt="composio jev integration" width="799" height="388"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Also keep the &lt;strong&gt;user ID&lt;/strong&gt; for that connection. We'll need it because every Composio tool call is scoped to a user.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7xajnq5mf7224vis38t6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7xajnq5mf7224vis38t6.png" alt="composio jev connection" width="800" height="456"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Discord and Twitch Setup
&lt;/h3&gt;

&lt;p&gt;This part can easily become its own guide, so I'll keep it short.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;ℹ️ You can find proper information in the &lt;a href="https://docs.discord.com/developers/intro" rel="noopener noreferrer"&gt;Discord&lt;/a&gt; and &lt;a href="https://dev.twitch.tv/docs" rel="noopener noreferrer"&gt;Twitch&lt;/a&gt; own documentation.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;For Discord:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create an application and bot in the &lt;a href="https://discord.com/developers/applications" rel="noopener noreferrer"&gt;Discord Developer Portal&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Copy the bot token.&lt;/li&gt;
&lt;li&gt;Turn on &lt;strong&gt;Message Content Intent&lt;/strong&gt; under the &lt;strong&gt;Bot&lt;/strong&gt; tab.&lt;/li&gt;
&lt;li&gt;Invite the bot with &lt;code&gt;View Channel&lt;/code&gt;, &lt;code&gt;Read Message History&lt;/code&gt;, and &lt;code&gt;Manage Messages&lt;/code&gt; permissions (&lt;code&gt;permissions=74752&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Create a webhook inside your test channel for the flooder.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without Message Content Intent, Discord will send messages to the bot without their content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For Twitch:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Register an app at &lt;a href="https://dev.twitch.tv/console/apps" rel="noopener noreferrer"&gt;dev.twitch.tv/console&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Set the client type to &lt;strong&gt;Public&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Copy the Client ID.&lt;/li&gt;
&lt;li&gt;Turn off AutoMod, blocked terms, and the non-mod chat delay while testing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Otherwise Twitch may remove the test messages before our bot even gets them.&lt;/p&gt;

&lt;p&gt;Now add everything to &lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;// 👇 .env

&lt;span class="nv"&gt;COMPOSIO_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;
&lt;span class="nv"&gt;COMPOSIO_USER_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;

&lt;span class="nv"&gt;DISCORD_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;
&lt;span class="nv"&gt;WATCH_CHANNEL_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;
&lt;span class="nv"&gt;FLOOD_WEBHOOK_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;

&lt;span class="nv"&gt;TWITCH_CLIENT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;COMPOSIO_API_KEY&lt;/code&gt; comes from the Composio dashboard.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;COMPOSIO_USER_ID&lt;/code&gt; is the user that owns your Jev connection.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5598mqobfvc3w2n52bl0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5598mqobfvc3w2n52bl0.png" alt="composio user id" width="800" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then we have the Discord bot token, channel ID, webhook URL, and Twitch Client ID. That's all we need for now. ✌️&lt;/p&gt;

&lt;h2&gt;
  
  
  The Jev Call
&lt;/h2&gt;

&lt;p&gt;This is basically the main part of the project.&lt;/p&gt;

&lt;p&gt;And the nice thing is that both bots use the same code.&lt;/p&gt;

&lt;p&gt;Create &lt;code&gt;jev-mod.mjs&lt;/code&gt;:&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="c1"&gt;// 👇 jev-mod.mjs&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;Composio&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;@composio/core&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;composio&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;Composio&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&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;COMPOSIO_API_KEY&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Both questions run in parallel inside one Jev call.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;QUESTIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;negative&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;noul&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;instructions&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 message is a hostile comment aimed at a person: an insult, harassment, a personal attack, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;or telling someone to leave. Casual swearing, self-deprecating jokes, complaining about a game &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;or product, and hype like "that was sick" do NOT count.&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;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;choice&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;criteria&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;insult&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Insults or belittles someone&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;harassment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Targets, dismisses or tells someone to go away&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;spam&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Repetitive, promotional or meaningless flooding&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;fine&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Normal chat, including jokes, hype and mild frustration&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;judge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&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;t0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&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;composio&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="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;JEV_EVALUATE_STATE&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;userId&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;COMPOSIO_USER_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;QUESTIONS&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;dangerouslySkipVersionCheck&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;ms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&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="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;t0&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;successful&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Bruhh! JEV_EVALUATE_STATE failed&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;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;answers&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="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;p&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;negative&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;noul&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;category&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;category&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;choice&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;There are two things worth looking at here. First is the &lt;code&gt;negative&lt;/code&gt; question.&lt;/p&gt;

&lt;p&gt;Chat messages can be weird. Someone might say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;this thing is absolutely killing me&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It has negative words in it, but isn't an attack on another person.&lt;/p&gt;

&lt;p&gt;So I don't just tell Jev what counts as hostile. I also tell it what &lt;strong&gt;doesn't&lt;/strong&gt; count.&lt;/p&gt;

&lt;p&gt;Then we have &lt;code&gt;category&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This one isn't actually used to decide whether a message gets deleted. I mainly added it because seeing &lt;code&gt;insult&lt;/code&gt;, &lt;code&gt;harassment&lt;/code&gt;, &lt;code&gt;spam&lt;/code&gt;, or &lt;code&gt;fine&lt;/code&gt; in the logs makes debugging much easier.&lt;/p&gt;

&lt;p&gt;And since both questions run in the same Jev call, we get it without adding another request.&lt;/p&gt;

&lt;p&gt;That's basically the entire AI part. 🔥&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;NOTE:&lt;/strong&gt; Direct tool execution in Composio needs a toolkit version. You can either pin one with &lt;code&gt;version: "&amp;lt;toolkit-version&amp;gt;"&lt;/code&gt; or use &lt;code&gt;dangerouslySkipVersionCheck: true&lt;/code&gt; to run the latest version. If you use neither, the SDK throws a &lt;code&gt;ToolVersionRequiredError&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Discord Bot
&lt;/h2&gt;

&lt;p&gt;Now we can actually use it.&lt;/p&gt;

&lt;p&gt;The Discord bot is a normal &lt;code&gt;discord.js&lt;/code&gt; client.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💁 You'll find ton of guides on the internet that show you how to use &lt;code&gt;discord.js&lt;/code&gt;. I also wrote one for freeCodeCamp, so if you're not sure how to get started, you might want to check it out: &lt;a href="https://www.freecodecamp.org/news/build-a-langgraph-composio-powered-discord-bot/" rel="noopener noreferrer"&gt;How to Build a LangGraph and Composio-Powered Discord Bot&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It listens for a message, sends the text to &lt;code&gt;judge()&lt;/code&gt;, and then decides what to do.&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="c1"&gt;// 👇 jev-mod.mjs&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;Client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;GatewayIntentBits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Events&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;discord.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;DELETE_AT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// noul&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;REVIEW_AT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&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;intents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nx"&gt;GatewayIntentBits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Guilds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;GatewayIntentBits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GuildMessages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;GatewayIntentBits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MessageContent&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MessageCreate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&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="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&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="k"&gt;return&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;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;channelId&lt;/span&gt; &lt;span class="o"&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;WATCH_CHANNEL_ID&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="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;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;judge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&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;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;DELETE_AT&lt;/span&gt;&lt;span class="p"&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;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`🗑️ DELETE  jev &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;ms  p=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&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="s2"&gt;  "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&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;span class="k"&gt;else&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;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;REVIEW_AT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`🟡 REVIEW  p=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&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="s2"&gt;  "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&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;span class="c1"&gt;// rest of the code…&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;login&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;DISCORD_TOKEN&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you've built a Discord bot before, most of this should look familiar.&lt;/p&gt;

&lt;p&gt;The interesting part is the two thresholds. Why not just have one?&lt;/p&gt;

&lt;p&gt;Because automatically deleting someone's message is a pretty strong action.&lt;/p&gt;

&lt;p&gt;If Jev gives us a high probability, we remove it.&lt;/p&gt;

&lt;p&gt;If the probability is somewhere in the middle, we simply log it and let a human take a look. 🧐&lt;/p&gt;

&lt;p&gt;That gives us a nice middle ground instead of forcing every message into delete or keep.&lt;/p&gt;

&lt;p&gt;The full repo adds two more things. There is a small limit on how many Jev requests can run at once, so a sudden flood does not fire hundreds of calls together.&lt;/p&gt;

&lt;p&gt;There is also a delete queue for every channel.&lt;/p&gt;

&lt;p&gt;When messages start piling up, it uses &lt;code&gt;bulkDelete()&lt;/code&gt; instead of deleting every message separately.&lt;/p&gt;

&lt;p&gt;Discord rate limits individual deletes pretty quickly, so this helps a lot during the flood test. ✅&lt;/p&gt;

&lt;h2&gt;
  
  
  The Twitch Bot
&lt;/h2&gt;

&lt;p&gt;Twitch needs a little more work.&lt;/p&gt;

&lt;p&gt;There isn't really a &lt;code&gt;discord.js&lt;/code&gt; equivalent that we are using here, so I talk to Twitch directly.&lt;/p&gt;

&lt;p&gt;New chat messages come through &lt;strong&gt;EventSub over WebSocket&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That means Twitch sends the message to us when it happens instead of making us keep checking for new messages.&lt;/p&gt;

&lt;p&gt;When the WebSocket connects, Twitch sends a welcome message containing a session ID.&lt;/p&gt;

&lt;p&gt;We then have &lt;strong&gt;10 seconds&lt;/strong&gt; to subscribe to the chat events using that ID.&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="c1"&gt;// 👇 twitch-mod.mjs&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ws&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;WebSocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;wss://eventsub.wss.twitch.tv/ws&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;data&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;msg&lt;/span&gt; &lt;span class="o"&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;data&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;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message_type&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;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;session_welcome&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;// Must subscribe within 10 s of the welcome.&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;helix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bot&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;POST&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;/eventsub/subscriptions&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;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;channel.chat.message&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&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;broadcaster_user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;broadcasterId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;me&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user_id&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;transport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;websocket&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;session&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;else&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;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;notification&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;onChatMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;event&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onChatMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ev&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;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;judge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 👈 the exact same Jev call&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;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;DELETE_AT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;helix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bot&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;DELETE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;`/moderation/chat?broadcaster_id=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;broadcasterId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;moderator_id=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;me&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;message_id=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message_id&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;span class="c1"&gt;// rest of the code…&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And notice this line:&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;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;judge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the exact same function we used for Discord. Nothing changes on the Jev side.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;helix()&lt;/code&gt; is just a small &lt;code&gt;fetch&lt;/code&gt; wrapper. It adds the token and Client ID headers and refreshes the token when needed.&lt;/p&gt;

&lt;p&gt;For Twitch login, I use the &lt;strong&gt;Device Code flow&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The script prints a link and code, you approve it in your browser, and you're done.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz5bbri8of176ubt1sft7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz5bbri8of176ubt1sft7.png" alt="Twitch connection approval" width="602" height="360"&gt;&lt;/a&gt;&lt;br&gt;
So we don't need to set up a redirect server just for authentication.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;NOTE:&lt;/strong&gt; Twitch does not let you delete messages from the broadcaster or other moderators. If you test this by sending bad messages from your own broadcaster account, the bot won't be able to remove them. Use a second regular account for testing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The repo also handles &lt;code&gt;session_reconnect&lt;/code&gt; and checks whether the EventSub connection is still alive.&lt;/p&gt;

&lt;p&gt;Connections can drop, and having the mod bot silently stop receiving messages in the middle of a stream would be... not great. 🫠&lt;/p&gt;
&lt;h2&gt;
  
  
  Flooding the Chat
&lt;/h2&gt;

&lt;p&gt;Now we need to actually test the thing.&lt;/p&gt;

&lt;p&gt;Sending one bad message manually isn't very useful.&lt;/p&gt;

&lt;p&gt;So the repo includes a flooder that sends a random mix of hostile and normal messages.&lt;/p&gt;

&lt;p&gt;Run the bot in one terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run bot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then run the flooder in another:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run flood &lt;span class="nt"&gt;--&lt;/span&gt; 60
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;60&lt;/code&gt; means it will send 60 test messages.&lt;/p&gt;

&lt;p&gt;For Twitch, use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run twitch:bot
npm run twitch:flood &lt;span class="nt"&gt;--&lt;/span&gt; 60
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Bot in Action
&lt;/h2&gt;

&lt;p&gt;Here's a quick demo of the Discord bot:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/Q_PjOIeu9vc" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;And for the Twitch one, simply run the command I gave you earlier. That should do it.&lt;/p&gt;

&lt;p&gt;While the bot is running, you'll see logs that look something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✅ keep    jev  389ms  p=0.06 fine ...
🟡 REVIEW  jev  909ms  p=0.73 insult ...
✅ keep    jev  372ms  p=0.03 fine ...

🗑️  DELETE  jev  341ms  delete  641ms  total  984ms  p=0.92 insult ...
🗑️  DELETE  jev  399ms  delete  418ms  total  979ms  p=0.93 insult ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's what those numbers mean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;jev&lt;/code&gt; is the time Jev took to make the decision.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;delete&lt;/code&gt; is how long Discord took to remove the message.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;total&lt;/code&gt; is the full time from receiving the message to deleting it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once the connection set up properly, Jev was usually around &lt;strong&gt;340 to 460 ms&lt;/strong&gt; in my test.&lt;/p&gt;

&lt;p&gt;Funny enough, Discord deleting the message was sometimes slower than Jev deciding whether it should be deleted. 😭&lt;/p&gt;

&lt;p&gt;From my first 10-message Discord run:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Jev latency (p50)&lt;/td&gt;
&lt;td&gt;399 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Receive → deleted (p50)&lt;/td&gt;
&lt;td&gt;979 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bad messages caught&lt;/td&gt;
&lt;td&gt;4 of 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Normal messages deleted&lt;/td&gt;
&lt;td&gt;0 of 5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One message did not cross the delete threshold:&lt;/p&gt;

&lt;p&gt;If you want a stricter bot, you can simply lower &lt;code&gt;DELETE_AT&lt;/code&gt;. That's also why I like using probabilities here.&lt;/p&gt;

&lt;h2&gt;
  
  
  What About YouTube Live?
&lt;/h2&gt;

&lt;p&gt;I left YouTube Live as the one for you to finish. 🙂&lt;/p&gt;

&lt;p&gt;The same idea works there, but testing it takes a little more setup.&lt;/p&gt;

&lt;p&gt;You can poll &lt;code&gt;liveChatMessages.list&lt;/code&gt;, and YouTube tells you how long to wait before making the next request.&lt;/p&gt;

&lt;p&gt;There is also a newer &lt;code&gt;streamList&lt;/code&gt; method that streams messages, which is probably what I would try for this project.&lt;/p&gt;

&lt;p&gt;But the important bit is that &lt;strong&gt;the Jev code does not change&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here's a rough version using &lt;code&gt;googleapis&lt;/code&gt;:&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="c1"&gt;// 👇 youtube-mod.mjs&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;google&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;googleapis&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// OAuth client for the channel owner or a moderator, with the youtube.force-ssl scope&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;youtube&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;google&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;youtube&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;v3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;oauthClient&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="na"&gt;data&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;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;youtube&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;liveBroadcasts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;part&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;snippet&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;broadcastStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;active&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;liveChatId&lt;/span&gt; &lt;span class="o"&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;items&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="nx"&gt;snippet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;liveChatId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;pageToken&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;while &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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&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;youtube&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;liveChatMessages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;liveChatId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;part&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;snippet&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;authorDetails&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="nx"&gt;pageToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// The first page is chat history, so skip it.&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;first&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &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;m&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&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;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;judge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snippet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;displayMessage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// same Jev call again&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;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.8&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;youtube&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;liveChatMessages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&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;m&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="nx"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;pageToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextPageToken&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pollingIntervalMillis&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;Replace the polling part with &lt;code&gt;streamList&lt;/code&gt;, add the same review threshold and request limit we used for Discord, and you basically have the third bot.&lt;/p&gt;

&lt;p&gt;Try it and let me know what kind of latency you get. ✌️&lt;/p&gt;

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

&lt;p&gt;And that's basically it. 🙌&lt;/p&gt;

&lt;p&gt;We built the same real-time moderation flow for Discord and Twitch using one shared Jev function.&lt;/p&gt;

&lt;p&gt;In my small Discord test, the full receive-to-delete flow came in at around a second. And the actual AI part is tiny.&lt;/p&gt;

&lt;p&gt;It's just this:&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;const&lt;/span&gt; &lt;span class="nx"&gt;res&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;composio&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="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;JEV_EVALUATE_STATE&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;userId&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;COMPOSIO_USER_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;QUESTIONS&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;dangerouslySkipVersionCheck&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finally, you don't have to wait on an LLM sending response back 10s after you send the msg. This is exactly why I did this blog post as well. Right model with right pricing for exactly this tyoe of tasks.&lt;/p&gt;

&lt;p&gt;If you find a bug, want to improve something, or manage to get the YouTube version working, feel free to open an issue or PR.&lt;/p&gt;

&lt;p&gt;You can find the entire project here: &lt;a href="https://github.com/shricodev/jev-youtube-discord-moderator" rel="noopener noreferrer"&gt;shricodev/jev-youtube-discord-moderator&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's all for this one. I'll see you in the next one. ✌️&lt;/p&gt;

</description>
      <category>ai</category>
      <category>jev</category>
      <category>tutorial</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I Tested 7 Document Parsing APIs on Real Scanned PDFs (2026): Code and Results</title>
      <dc:creator>Shrijal Acharya</dc:creator>
      <pubDate>Mon, 21 Sep 2026 12:56:36 +0000</pubDate>
      <link>https://dev.to/shricodev/i-tested-7-document-parsing-apis-on-real-scanned-pdfs-2026-code-and-results-3h2b</link>
      <guid>https://dev.to/shricodev/i-tested-7-document-parsing-apis-on-real-scanned-pdfs-2026-code-and-results-3h2b</guid>
      <description>&lt;p&gt;Every "best document parsing API" post I have read this year has the same problem. Nobody actually calls the APIs.&lt;/p&gt;

&lt;p&gt;They copy the pricing page, add a feature table, rank everything, and call it a day. Zero PDFs were harmed in the making of that content. 🥴&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp8nqpziik5d1hdbm9db0.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp8nqpziik5d1hdbm9db0.gif" alt="disappointed" width="480" height="270"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So I did something else. I took 11 real documents from public datasets. Invoices, a French bank statement, a photographed receipt, a handwritten cheque, two contracts, and a medical EOB.&lt;/p&gt;

&lt;p&gt;Then I wrote one JSON schema for each document type and sent the exact same files through Extend, Reducto, LlamaExtract, AWS Textract, Mistral OCR, Claude, and GPT. 119 API calls in total.&lt;/p&gt;

&lt;p&gt;I saved every response too, so you can check the work yourself. This one got interesting pretty fast.&lt;/p&gt;

&lt;p&gt;Let's get into it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F76bwj222rqa93lrbe2qg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F76bwj222rqa93lrbe2qg.png" alt="document corpus for the test" width="800" height="821"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;ℹ️ &lt;strong&gt;NOTE&lt;/strong&gt; Everything below comes from stored API responses. The harness is public too, so you do not have to take my word for any of it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  TLDR
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Winner&lt;/th&gt;
&lt;th&gt;Runner up&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Raw field accuracy&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Claude direct&lt;/strong&gt; 0.991&lt;/td&gt;
&lt;td&gt;GPT direct and Reducto 0.982&lt;/td&gt;
&lt;td&gt;11 docs, fixed scoring, no LLM judge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Never hallucinated a value&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Extend&lt;/strong&gt; 0 out of 11&lt;/td&gt;
&lt;td&gt;Claude, GPT, Reducto also 0&lt;/td&gt;
&lt;td&gt;Textract 2, Mistral 3, LlamaExtract 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Table rows never missed&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Extend&lt;/strong&gt; row F1 1.00&lt;/td&gt;
&lt;td&gt;Everyone else 0.99&lt;/td&gt;
&lt;td&gt;Only perfect row F1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cheapest per correct field&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;GPT direct&lt;/strong&gt; $0.00047&lt;/td&gt;
&lt;td&gt;Mistral OCR $0.00049&lt;/td&gt;
&lt;td&gt;Textract was 14x GPT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fastest&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Mistral OCR&lt;/strong&gt; 4.2s median&lt;/td&gt;
&lt;td&gt;Claude direct 6.4s&lt;/td&gt;
&lt;td&gt;Mistral also hallucinated 3 times&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Developer experience&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Claude and GPT&lt;/strong&gt; about 10 min&lt;/td&gt;
&lt;td&gt;Extend about 25 min&lt;/td&gt;
&lt;td&gt;Textract took about 45 min plus another LLM step&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent support&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Extend&lt;/strong&gt; llms.txt, agents.md, MCP, plugin&lt;/td&gt;
&lt;td&gt;Reducto 3 of 4&lt;/td&gt;
&lt;td&gt;AWS has nothing specific for Textract&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hardest to justify&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;AWS Textract&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;Highest cost in this test and some ugly misses&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The short version is pretty simple. Claude had the best raw accuracy. GPT was the cheapest per correct field. Extend was the only document API with &lt;strong&gt;zero hallucinations and perfect row recall&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That last part matters a lot once the output is going into a real system. If a human is checking the JSON, raw accuracy can be enough. If software is going to act on the result, I care a lot more about made up values.&lt;/p&gt;

&lt;p&gt;Textract had the roughest run here. I will show you why.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcxzew491d14bkvytb0f2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcxzew491d14bkvytb0f2.png" alt="cost per accuracy chart" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;There are six products in the test. Claude and GPT run directly, so the result table has seven columns.&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;Provider&lt;/th&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;Native schema&lt;/th&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Extend&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /extract&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;default, API &lt;code&gt;2026-02-09&lt;/code&gt;, &lt;code&gt;extend-ai&lt;/code&gt; 1.19.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Reducto&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /extract&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;standard, &lt;code&gt;deep_extract&lt;/code&gt; off&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;LlamaExtract&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;extract_stateless()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BALANCED&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;AWS Textract&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AnalyzeDocument&lt;/code&gt; FORMS + TABLES&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;plus LLM normalizer, us-east-1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Mistral OCR&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/v1/ocr&lt;/code&gt; + &lt;code&gt;document_annotation_format&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mistral-ocr-latest&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6a&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Claude direct&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/v1/messages&lt;/code&gt;, forced tool&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;claude-opus-5&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6b&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;GPT direct&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/v1/responses&lt;/code&gt;, &lt;code&gt;json_schema&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gpt-5.5&lt;/code&gt;, &lt;code&gt;strict: false&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A couple of things before we start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LlamaParse structured output is deprecated.&lt;/strong&gt; If you want schema extraction on LlamaCloud now, you use LlamaExtract.&lt;/p&gt;

&lt;p&gt;The old &lt;code&gt;llama-cloud-services&lt;/code&gt; package is also deprecated, so I used &lt;code&gt;llama-cloud&amp;gt;=1.0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AWS Textract does not return your schema as JSON.&lt;/strong&gt; It gives you blocks like &lt;code&gt;LINE&lt;/code&gt;, &lt;code&gt;KEY_VALUE_SET&lt;/code&gt;, &lt;code&gt;TABLE&lt;/code&gt;, and &lt;code&gt;CELL&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So I had to turn those blocks into text and pass that text to &lt;code&gt;claude-haiku-4-5&lt;/code&gt; to get the final JSON. That extra model call counts toward Textract cost and latency.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 In simple words, Textract could read the page, but it needed another model to shape that reading into the same output every other tool was asked to return.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Keep that in mind for the numbers later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The documents
&lt;/h2&gt;

&lt;p&gt;I used 11 documents and 35 pages in total. All of them came from public datasets.&lt;/p&gt;

&lt;p&gt;The mix was roughly 3 easy, 4 medium, and 4 hard.&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;Document&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Pages&lt;/th&gt;
&lt;th&gt;Why it is here&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;01&lt;/td&gt;
&lt;td&gt;invoice_clean&lt;/td&gt;
&lt;td&gt;Voxel51 synthetic invoices&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;clean invoice, 7 rows, European decimals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;02&lt;/td&gt;
&lt;td&gt;invoice_complex&lt;/td&gt;
&lt;td&gt;FATURA2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;different layout, discount row, weird total&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;03&lt;/td&gt;
&lt;td&gt;receipt_photo&lt;/td&gt;
&lt;td&gt;SROIE 2019&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;low resolution and handwriting on top&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;04&lt;/td&gt;
&lt;td&gt;contract_simple&lt;/td&gt;
&lt;td&gt;CUAD&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;agreement date is blank&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;05&lt;/td&gt;
&lt;td&gt;contract_complex&lt;/td&gt;
&lt;td&gt;CUAD pages 1 to 8&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;governing law is outside the page limit and &lt;code&gt;$6,200,000&lt;/code&gt; is not the contract value&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;06&lt;/td&gt;
&lt;td&gt;bank_statement&lt;/td&gt;
&lt;td&gt;Bankstatemently&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;French Canadian, 25 transactions, watermark, 2 digit years&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;07&lt;/td&gt;
&lt;td&gt;noisy_form&lt;/td&gt;
&lt;td&gt;FUNSD&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;scanned form with 41 label and value pairs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;08&lt;/td&gt;
&lt;td&gt;handwritten_note&lt;/td&gt;
&lt;td&gt;handwritten cheque dataset&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;written amount and number box disagree&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;09&lt;/td&gt;
&lt;td&gt;EOB&lt;/td&gt;
&lt;td&gt;CMS sample&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;medical EOB with &lt;code&gt;XXXXXX&lt;/code&gt; placeholders&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;mixed_packet&lt;/td&gt;
&lt;td&gt;built from 01, 04, and 09&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;checks page boundaries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;contract_scanned&lt;/td&gt;
&lt;td&gt;scanned copy of 04&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;same contract, but rasterised at 200dpi&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every file is locked with a sha256 in &lt;code&gt;manifest.lock.json&lt;/code&gt;. If one byte changes, the runner stops.&lt;/p&gt;

&lt;p&gt;That sounds a bit extreme, but it saves a lot of headaches later.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 It means every provider saw the exact same file. No accidental OCR. No changed PDF. No weird hidden advantage.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I also made every image source into a &lt;strong&gt;pixel only PDF&lt;/strong&gt; with &lt;code&gt;img2pdf&lt;/code&gt;. Then the build script checks it with &lt;code&gt;pdftotext&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If any text layer exists, the build fails. Why bother&lt;/p&gt;

&lt;p&gt;Because if I OCR the file before the benchmark, I have basically handed every provider the answer key. And that kinda kills the whole test. 💁&lt;/p&gt;

&lt;h2&gt;
  
  
  The traps
&lt;/h2&gt;

&lt;p&gt;This is probably the most useful part of the corpus.&lt;/p&gt;

&lt;p&gt;A few fields are genuinely missing from the documents. The right answer for those is &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If a provider gives me a value anyway, it made something up.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Doc 04&lt;/strong&gt; has a blank agreement date. The effective date exists, so it is very easy to copy the wrong date.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Doc 05&lt;/strong&gt; stops at page 8. The governing law is on page 26. It also contains &lt;code&gt;$6,200,000&lt;/code&gt;, but that number is a financing cap, not the contract value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Doc 01&lt;/strong&gt; has no due date and no discount.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Doc 08&lt;/strong&gt; disagrees with itself. The words say one amount and the number box says another. The correct output keeps both.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg4qdt3nz83pxbyyqnwyf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg4qdt3nz83pxbyyqnwyf.png" alt="document financing cap" width="800" height="195"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffqocrzhz3q87g836yevs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffqocrzhz3q87g836yevs.png" alt="cheque document" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 These are not OCR tricks. I wanted to see what happens when the safest answer is simply "I do not know".&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How I kept the test fair
&lt;/h2&gt;

&lt;p&gt;The numbers are useless if every provider gets a different setup. So each one got the same thing.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The exact same PDF&lt;/li&gt;
&lt;li&gt;The exact same JSON schema&lt;/li&gt;
&lt;li&gt;The exact same instruction&lt;/li&gt;
&lt;li&gt;The same 2 second poll time and 300 second timeout&lt;/li&gt;
&lt;li&gt;Retries only for network or server errors&lt;/li&gt;
&lt;li&gt;The normal default mode for each product&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No custom prompt for one provider. No premium mode for another.&lt;/p&gt;

&lt;p&gt;A wrong answer did not get a second try. The prompt was tiny too.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# bench/providers/base.py
&lt;/span&gt;&lt;span class="n"&gt;SHARED_INSTRUCTIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Extract the fields defined by the schema from this document. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Use exactly the values printed in the document. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Return dates as ISO 8601 (YYYY-MM-DD) and amounts as plain numbers &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;without currency symbols or thousands separators. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;If a field is not present in the document, return null for it &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rather than guessing or inferring a value.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;POLL_INTERVAL_S&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="n"&gt;TIMEOUT_S&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a schema looks like this.&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"properties"&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;"vendor_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"null"&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;"due_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"null"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ISO 8601"&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;"line_items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"array"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"properties"&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;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"null"&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;"quantity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"number"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"null"&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;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"number"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"null"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"line total EXCLUDING tax"&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="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 important bit is that fields can return &lt;code&gt;null&lt;/code&gt;. That lets the provider say the value is missing instead of forcing it to guess.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;code&gt;null&lt;/code&gt; is not a failure here. Sometimes it is exactly the right answer.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That &lt;code&gt;"line total EXCLUDING tax"&lt;/code&gt; line exists because I got burned while building this. My first schema just said &lt;code&gt;amount&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The invoice has Net price, Net worth, and Gross worth. Every provider picked something different and for about an hour I thought I had found a huge accuracy gap.&lt;/p&gt;

&lt;p&gt;But nope.&lt;/p&gt;

&lt;p&gt;My schema was just vague. 🫠&lt;/p&gt;

&lt;h3&gt;
  
  
  One adapter for every provider
&lt;/h3&gt;

&lt;p&gt;Every company has a completely different way to say "send PDF, give JSON".&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;extend        POST /extract         upload -&amp;gt; file id   -&amp;gt; inline schema config
reducto       POST /extract         upload -&amp;gt; reducto:// -&amp;gt; instructions.schema
mistral       POST /v1/ocr          upload -&amp;gt; signed URL -&amp;gt; document_annotation_format
llamaextract  extract_stateless()   upload -&amp;gt; file id   -&amp;gt; ExtractConfig, maybe poll
claude        POST /v1/messages     base64 inline       -&amp;gt; forced tool, input_schema IS the schema
gpt           POST /v1/responses    base64 inline       -&amp;gt; text.format json_schema
textract      AnalyzeDocument       per-page blocks     -&amp;gt; LLM normalizer stage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Some upload first. Some want a signed URL. Some poll. Some return the JSON inside another object.&lt;/p&gt;

&lt;p&gt;Mistral even returns &lt;code&gt;document_annotation&lt;/code&gt; as a JSON string. Thanks Mistral. 😭&lt;/p&gt;

&lt;p&gt;I hide all of that behind one function.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Provider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Protocol&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ExtractResult&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is what keeps the runner fair. The runner does not know which provider it is calling.&lt;/p&gt;

&lt;p&gt;It just sends a PDF and expects the same result shape back.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff1wk25wl4sza5jhuw1pf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff1wk25wl4sza5jhuw1pf.png" alt="architecture" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Claude adapter is tiny.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# bench/providers/direct_llm.py (trimmed)
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ExtractResult&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;b64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_bytes&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude-opus-5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8192&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;emit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Return the extracted fields.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input_schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
        &lt;span class="n"&gt;tool_choice&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;emit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;document&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;base64&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;media_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SHARED_INSTRUCTIONS&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="n"&gt;block&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool_use&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ExtractResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_anthropic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_dump&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;No upload. No job ID. No polling.&lt;/p&gt;

&lt;p&gt;The schema itself becomes the tool input.&lt;/p&gt;

&lt;p&gt;Textract is the opposite. It needs about 60 lines just to rebuild rows from &lt;code&gt;CELL&lt;/code&gt; blocks, then another model call to turn that into the target JSON.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# bench/providers/textract.py (trimmed)
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_table_rows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;blocks_by_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;cells&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="n"&gt;blocks_by_id&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Relationships&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CHILD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Ids&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;grid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cells&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RowIndex&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]][&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ColumnIndex&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_cell_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;blocks_by_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&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;[&lt;/span&gt;&lt;span class="n"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;])]&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;normalized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;normalizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;rendered&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 Same task on paper. Very different amount of work in code.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Scoring without an LLM judge
&lt;/h3&gt;

&lt;p&gt;I did not use another LLM to decide who won. That just creates a second argument.&lt;/p&gt;

&lt;p&gt;The scorer is fixed.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Normalise first&lt;/strong&gt; Money becomes &lt;code&gt;Decimal&lt;/code&gt;, dates become ISO, and strings get cleaned so tiny formatting changes do not count as errors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scalars&lt;/strong&gt; Exact after normalisation. Longer text uses &lt;code&gt;rapidfuzz token_set_ratio&lt;/code&gt;. A score of 90 or more counts as correct.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verdicts&lt;/strong&gt; A field can be &lt;code&gt;correct&lt;/code&gt;, &lt;code&gt;near&lt;/code&gt;, &lt;code&gt;wrong&lt;/code&gt;, &lt;code&gt;missing&lt;/code&gt;, or &lt;code&gt;hallucinated&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Arrays&lt;/strong&gt; Rows are matched using text fields only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missed rows count&lt;/strong&gt; If a provider returns 1 perfect row out of 7, it does not magically get 100 percent. 🥴&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before scoring the providers, the scorer checks itself. First, all 11 ground truth files are scored against themselves.&lt;/p&gt;

&lt;p&gt;Every one must return exactly &lt;code&gt;1.000&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then I deliberately break the data. Wrong total. Fake date. Missing vendor. Dropped rows.&lt;/p&gt;

&lt;p&gt;Every one of those changes has to lower the score. Dropping 4 of 7 rows gives &lt;code&gt;0.579&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Not some fake &lt;code&gt;1.0&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ uv run bench score
self-test  identity     11/11  =1.000  OK
self-test  corruption   6/6    reduce  OK
scoring 77 runs ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3cnij9gp1cvjgq3dxs6s.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3cnij9gp1cvjgq3dxs6s.gif" alt="harness demo" width="760" height="415"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;run&lt;/code&gt; and &lt;code&gt;score&lt;/code&gt; are separate commands. &lt;code&gt;run&lt;/code&gt; saves the raw API response to disk. &lt;code&gt;score&lt;/code&gt; only reads those saved files.&lt;/p&gt;

&lt;p&gt;That meant I could rewrite the scorer four times without paying for the APIs again. Pretty useful when you find a bug at 1 AM. 👍&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Accuracy
&lt;/h2&gt;

&lt;p&gt;There were 77 main runs. Zero API errors. Zero invalid JSON responses.&lt;/p&gt;

&lt;p&gt;Every provider returned something usable on every document.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Field accuracy&lt;/th&gt;
&lt;th&gt;Row F1&lt;/th&gt;
&lt;th&gt;Hallucinated&lt;/th&gt;
&lt;th&gt;Missing&lt;/th&gt;
&lt;th&gt;Median latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Claude direct&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.991&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.99&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;6.4s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GPT direct&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.982&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.99&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;10.1s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Reducto&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.982&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.99&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;10.1s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Extend&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.962&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.00&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;22.1s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS Textract&lt;/td&gt;
&lt;td&gt;0.936&lt;/td&gt;
&lt;td&gt;0.99&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;14.4s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LlamaExtract&lt;/td&gt;
&lt;td&gt;0.903&lt;/td&gt;
&lt;td&gt;0.99&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;22.5s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mistral OCR&lt;/td&gt;
&lt;td&gt;0.884&lt;/td&gt;
&lt;td&gt;0.99&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.2s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I kept field accuracy and row F1 separate. I did not turn everything into some made up score out of 10.&lt;/p&gt;

&lt;p&gt;Because then half the benchmark becomes me deciding how much each number should matter.&lt;/p&gt;

&lt;p&gt;Claude came first on raw field accuracy. GPT and Reducto tied next.&lt;/p&gt;

&lt;p&gt;Extend landed at 0.962, but it had the only perfect row F1 in the whole test. It did not miss a single table row across 25 bank transactions, 7 invoice lines, 41 form pairs, and all the EOB rows.&lt;/p&gt;

&lt;p&gt;That is a pretty important detail. 0.991 and 0.962 look different on a leaderboard.&lt;/p&gt;

&lt;p&gt;Across these 11 documents, the gap was under 3 points.&lt;/p&gt;

&lt;h3&gt;
  
  
  Per document
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9fqdt152eifno15bdlnk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9fqdt152eifno15bdlnk.png" alt="per document heatmap" width="800" height="623"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Document&lt;/th&gt;
&lt;th&gt;Claude&lt;/th&gt;
&lt;th&gt;GPT&lt;/th&gt;
&lt;th&gt;Reducto&lt;/th&gt;
&lt;th&gt;Extend&lt;/th&gt;
&lt;th&gt;Textract&lt;/th&gt;
&lt;th&gt;LlamaExtract&lt;/th&gt;
&lt;th&gt;Mistral&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;01_invoice_clean&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;02_invoice_complex&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;0.97&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;03_receipt_photo&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;0.92&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;04_contract_simple&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;0.93&lt;/td&gt;
&lt;td&gt;0.86&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;0.86&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;05_contract_complex&lt;/td&gt;
&lt;td&gt;0.93&lt;/td&gt;
&lt;td&gt;0.93&lt;/td&gt;
&lt;td&gt;0.86&lt;/td&gt;
&lt;td&gt;0.93&lt;/td&gt;
&lt;td&gt;0.79&lt;/td&gt;
&lt;td&gt;0.86&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.64&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;06_bank_statement&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;0.99&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;0.98&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;0.99&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;07_noisy_form&lt;/td&gt;
&lt;td&gt;0.98&lt;/td&gt;
&lt;td&gt;0.95&lt;/td&gt;
&lt;td&gt;0.95&lt;/td&gt;
&lt;td&gt;0.95&lt;/td&gt;
&lt;td&gt;0.86&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.74&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.93&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;08_handwritten_note&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.67&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.83&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;09_EOB&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.75&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.95&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10_mixed_packet&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;0.92&lt;/td&gt;
&lt;td&gt;0.92&lt;/td&gt;
&lt;td&gt;0.92&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.67&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11_contract_scanned&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;0.86&lt;/td&gt;
&lt;td&gt;0.93&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;0.86&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few things stand out.&lt;/p&gt;

&lt;p&gt;The clean invoice is a 7 way tie at 1.00. If your documents all look like that, honestly, pick the cheapest one and move on. 🤷‍♂️&lt;/p&gt;

&lt;p&gt;The complex contract is where Mistral drops hard to 0.64. LlamaExtract had the roughest time on the noisy form, handwriting, and EOB.&lt;/p&gt;

&lt;p&gt;Now the fun part.&lt;/p&gt;

&lt;p&gt;The failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Where things broke
&lt;/h2&gt;

&lt;p&gt;This is why I saved every raw response. A final accuracy number hides way too much.&lt;/p&gt;

&lt;h3&gt;
  
  
  The hallucination traps
&lt;/h3&gt;

&lt;p&gt;The first trap was the blank agreement date in Doc 04. The correct answer is &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Mistral returned &lt;code&gt;2012-02-17&lt;/code&gt;. Textract returned &lt;code&gt;2012-02-17&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Claude, GPT, Reducto, Extend, and LlamaExtract returned &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The second trap was the &lt;code&gt;$6,200,000&lt;/code&gt; financing cap in Doc 05. Again, the correct contract value is &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Mistral, LlamaExtract, and Textract returned &lt;code&gt;6200000&lt;/code&gt;. Claude, GPT, Reducto, and Extend returned &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Three products turned a financing limit into a contract value. That is not the kind of error I want quietly flowing into an AP system.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvwos9mbmbf0zc90j0l2h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvwos9mbmbf0zc90j0l2h.png" alt="financial document crop" width="800" height="195"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 The scary errors are not always random garbage. Sometimes the wrong answer looks completely reasonable.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Textract dropped digits from money
&lt;/h3&gt;

&lt;p&gt;On the bank statement, I found these two.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;1 200,45 $&lt;/code&gt; became &lt;strong&gt;&lt;code&gt;200.45&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;6 675,00 $&lt;/code&gt; became &lt;strong&gt;&lt;code&gt;675.00&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;These are not formatting misses. The first digit is just gone.&lt;/p&gt;

&lt;p&gt;And this is money. 🤧&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvolh03p0yi6hwk0oo5bx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvolh03p0yi6hwk0oo5bx.png" alt="textract dropped digit" width="800" height="733"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F30h33x0nnt4dpxrtdkmq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F30h33x0nnt4dpxrtdkmq.png" alt="textract dropped digit second" width="515" height="388"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I checked the summary on the same page too. The credits, debits, and closing balance all match the ground truth.&lt;/p&gt;

&lt;p&gt;Then I checked Textract raw blocks. The digit was already missing before Haiku saw anything.&lt;/p&gt;

&lt;p&gt;So the normalizer did not cause this one.&lt;/p&gt;

&lt;h3&gt;
  
  
  LlamaExtract shifted a whole column
&lt;/h3&gt;

&lt;p&gt;The EOB result was sneaky. LlamaExtract found the right rows, but some values moved one column to the left.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Expected&lt;/th&gt;
&lt;th&gt;LlamaExtract&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;services/0/plan_paid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2.15&lt;/td&gt;
&lt;td&gt;0.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;services/0/patient_responsibility&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.0&lt;/td&gt;
&lt;td&gt;2.15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;services/1/billed_amount&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;375.00&lt;/td&gt;
&lt;td&gt;118.12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;services/1/allowed_amount&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;118.12&lt;/td&gt;
&lt;td&gt;35.00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every number exists in the row. It is just under the wrong field.&lt;/p&gt;

&lt;p&gt;That is exactly the kind of JSON you can skim and think looks fine.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi8zfsyurgw0uk8ce4eq6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi8zfsyurgw0uk8ce4eq6.png" alt="EOB table" width="800" height="369"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Mistral found pages that do not exist
&lt;/h3&gt;

&lt;p&gt;The mixed packet has five pages. Mistral said the contract was on pages &lt;strong&gt;2 to 10&lt;/strong&gt; and the EOB was on &lt;strong&gt;11 to 13&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Every other provider got &lt;code&gt;1-1&lt;/code&gt;, &lt;code&gt;2-4&lt;/code&gt;, and &lt;code&gt;5-5&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I still do not know where page 13 came from. 🫩&lt;/p&gt;

&lt;h3&gt;
  
  
  Handwriting fooled GPT
&lt;/h3&gt;

&lt;p&gt;The receipt has &lt;code&gt;tan chay yee&lt;/code&gt; written by hand at the top. GPT used that as the &lt;code&gt;merchant_name&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The printed merchant is &lt;code&gt;OJC MARKETING SDN BHD&lt;/code&gt;. Every other provider ignored the handwriting.&lt;/p&gt;

&lt;p&gt;So yeah, best overall does not mean best on every file.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbv32miqq19balrhwrwb0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbv32miqq19balrhwrwb0.png" alt="document receipt" width="618" height="226"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Mistral answered a slightly different question
&lt;/h3&gt;

&lt;p&gt;Doc 05 calls the two parties &lt;code&gt;Sekisui&lt;/code&gt; and &lt;code&gt;Qualigen&lt;/code&gt;. Mistral returned &lt;code&gt;Distributor&lt;/code&gt; and &lt;code&gt;Manufacturer&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Those words make sense in the contract, but they are not the defined names I asked for. Part of this is schema sensitivity.&lt;/p&gt;

&lt;p&gt;Still, it cost two fields.&lt;/p&gt;

&lt;h3&gt;
  
  
  Apparently the pipe symbol was hard
&lt;/h3&gt;

&lt;p&gt;One invoice row says this.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Dell Core 2 Duo Desktop Computer | Windows XP Pro | 4GB | 500GB&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Textract read &lt;code&gt;|&lt;/code&gt; as &lt;code&gt;I&lt;/code&gt;. Mistral removed the pipes.&lt;/p&gt;

&lt;p&gt;Everyone else got it exactly right.&lt;/p&gt;

&lt;p&gt;Tiny character. Same invoice. Two different OCR mistakes.&lt;/p&gt;

&lt;p&gt;Lol.&lt;/p&gt;

&lt;h3&gt;
  
  
  Extend never made up a value
&lt;/h3&gt;

&lt;p&gt;Extend had a very different failure pattern.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Zero hallucinations across all 11 documents&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Passed both traps&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Only perfect row F1 at 1.00&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;8 of 11 documents scored 1.000&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;0.962 field accuracy compared with 0.991 for the leader&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most of the lost points came from the contracts. A couple of fields were present, but Extend returned &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is still wrong for the benchmark. But it is a much easier failure to deal with in a production flow.&lt;/p&gt;

&lt;p&gt;A missing value can go to review. A confident wrong number can move straight into the next system.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 Extend was more likely to leave a field empty than guess. Depending on your workflow, that can be a useful trade.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  3. Pricing
&lt;/h2&gt;

&lt;p&gt;Here is what the 35 page test cost.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Total&lt;/th&gt;
&lt;th&gt;Cost per document&lt;/th&gt;
&lt;th&gt;Cost per 1000 pages&lt;/th&gt;
&lt;th&gt;Cost per correct field&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GPT direct&lt;/td&gt;
&lt;td&gt;$0.1736&lt;/td&gt;
&lt;td&gt;$0.0158&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$4.96&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.00047&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mistral OCR&lt;/td&gt;
&lt;td&gt;$0.1750&lt;/td&gt;
&lt;td&gt;$0.0159&lt;/td&gt;
&lt;td&gt;$5.00&lt;/td&gt;
&lt;td&gt;$0.00049&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude direct&lt;/td&gt;
&lt;td&gt;$0.3734&lt;/td&gt;
&lt;td&gt;$0.0339&lt;/td&gt;
&lt;td&gt;$10.67&lt;/td&gt;
&lt;td&gt;$0.00100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LlamaExtract*&lt;/td&gt;
&lt;td&gt;$0.4375&lt;/td&gt;
&lt;td&gt;$0.0398&lt;/td&gt;
&lt;td&gt;$12.50&lt;/td&gt;
&lt;td&gt;$0.00126&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reducto*&lt;/td&gt;
&lt;td&gt;$0.5250&lt;/td&gt;
&lt;td&gt;$0.0477&lt;/td&gt;
&lt;td&gt;$15.00&lt;/td&gt;
&lt;td&gt;$0.00142&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Extend&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$2.1875&lt;/td&gt;
&lt;td&gt;$0.1989&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$62.50&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0.00593&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AWS Textract&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$2.3553&lt;/td&gt;
&lt;td&gt;$0.2141&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$67.30&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.00663&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;Pricing for Reducto and LlamaExtract could not be fully verified from usage data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;GPT was easily the cheapest per correct field. Textract was the most expensive in this test.&lt;/p&gt;

&lt;p&gt;And because it also needed the extra Haiku step, its real setup is not just the Textract call.&lt;/p&gt;

&lt;h3&gt;
  
  
  What you get with Extend pricing
&lt;/h3&gt;

&lt;p&gt;Extend reports both extraction credits and total credits.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"usage"&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;"credits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;3.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"totalCredits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;5.0&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;Across my runs, that came to &lt;strong&gt;5 credits per page&lt;/strong&gt;. On pay as you go pricing, that is about &lt;strong&gt;$62.50 per 1,000 pages&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;But you are also getting more than a JSON response. Extend includes parsing, structured extraction, field citations, confidence scores, and a review flow in the dashboard.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F13hz74g52s8ank141mr5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F13hz74g52s8ank141mr5.png" alt="Extend credit usage" width="799" height="424"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Scale pricing drops to roughly &lt;strong&gt;$50 per 1,000 pages&lt;/strong&gt;. Enterprise pricing is custom.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fchoyw25zq75wtms4j8rx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fchoyw25zq75wtms4j8rx.png" alt="extend run" width="800" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💁 So I would not compare this only as price per page. The extra cost is paying for the workflow around extraction too.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Textract pricing gets messy fast
&lt;/h3&gt;

&lt;p&gt;Textract prices features separately. FORMS is $0.05 per page. TABLES is $0.015 per page.&lt;/p&gt;

&lt;p&gt;Using both comes to $0.065 per page. For 35 pages, that was $2.275.&lt;/p&gt;

&lt;p&gt;Then I had about $0.08 of Haiku cost for the JSON normalizer. That brings the matrix to around $2.36.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdsevrmk0w2lddzii58sw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdsevrmk0w2lddzii58sw.png" alt="AWS billing" width="798" height="185"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The billing screenshot can show more than that because it also includes my reruns and integration tests.&lt;/p&gt;

&lt;p&gt;There is also &lt;code&gt;DetectDocumentText&lt;/code&gt;. That costs $0.0015 per page and has a bigger free tier.&lt;/p&gt;

&lt;p&gt;I did not test that setup here. But plain OCR plus a cheap model would be a very interesting follow up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reducto and LlamaExtract pricing
&lt;/h3&gt;

&lt;p&gt;I could not fully verify Reducto extraction cost from the usage dashboard. It was still showing the usage page as under construction when I checked.&lt;/p&gt;

&lt;p&gt;So the &lt;code&gt;$15 per 1,000 pages&lt;/code&gt; number uses the published parse rate. Extraction can add more credits.&lt;/p&gt;

&lt;p&gt;LlamaExtract uses its published rate in the table with the same caveat.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Developer experience
&lt;/h2&gt;

&lt;p&gt;I also tracked how annoying each integration was to build. Very scientific metric, I know. 😆&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rank&lt;/th&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Claude&lt;/td&gt;
&lt;td&gt;about 10 min&lt;/td&gt;
&lt;td&gt;worked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;GPT&lt;/td&gt;
&lt;td&gt;about 10 min&lt;/td&gt;
&lt;td&gt;worked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Extend&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;about 25 min&lt;/td&gt;
&lt;td&gt;documented path worked first try&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Reducto&lt;/td&gt;
&lt;td&gt;about 20 min&lt;/td&gt;
&lt;td&gt;runtime typing issue and serializer crash&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Mistral&lt;/td&gt;
&lt;td&gt;about 15 min&lt;/td&gt;
&lt;td&gt;documented import failed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;LlamaCloud&lt;/td&gt;
&lt;td&gt;about 30 min&lt;/td&gt;
&lt;td&gt;old package docs and mixed pydantic versions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;AWS Textract&lt;/td&gt;
&lt;td&gt;about 45 min&lt;/td&gt;
&lt;td&gt;no schema output, table rebuild code, extra LLM step&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Claude and GPT were simple because the PDF can go straight into the model call. Extend was the easiest document API.&lt;/p&gt;

&lt;p&gt;Its documented path worked on the first try. That should be normal.&lt;/p&gt;

&lt;p&gt;It was not. ✌️&lt;/p&gt;

&lt;h3&gt;
  
  
  Mistral import issue
&lt;/h3&gt;

&lt;p&gt;The docs showed this.&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;On v2.9.4 I got this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ImportError: cannot import name 'Mistral' from 'mistralai' (unknown location)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The working import was this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mistralai.client&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Mistral&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;15 minutes of my life. 😴&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpu07kv0c1v3asftvu7oj.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpu07kv0c1v3asftvu7oj.gif" alt="Mistral error importing" width="800" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  LlamaCloud setup
&lt;/h3&gt;

&lt;p&gt;LlamaCloud sent me through two old paths before I even started. LlamaParse structured output is deprecated.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;llama-cloud-services&lt;/code&gt; is also deprecated.&lt;/p&gt;

&lt;p&gt;Then the client lives at &lt;code&gt;llama_cloud.client.LlamaCloud&lt;/code&gt;, and &lt;code&gt;ExtractConfig&lt;/code&gt; still uses pydantic v1 style while the rest of the package uses v2.&lt;/p&gt;

&lt;p&gt;It works. It just took more digging than I expected.&lt;/p&gt;

&lt;h3&gt;
  
  
  Extend setup
&lt;/h3&gt;

&lt;p&gt;Extend was boring in the best way. The documented SDK flow worked.&lt;/p&gt;

&lt;p&gt;No weird import hunt. No second package. No guess and check loop.&lt;/p&gt;

&lt;p&gt;That is also where its agent files helped.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Agent experience
&lt;/h2&gt;

&lt;p&gt;This part is becoming more important now. Can Claude Code, Codex, or another coding agent actually understand the SDK without me spoon feeding it everything&lt;/p&gt;

&lt;p&gt;I checked what each vendor ships for agents.&lt;/p&gt;

&lt;h3&gt;
  
  
  What vendors publish
&lt;/h3&gt;

&lt;p&gt;Checked on September 8 2026.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;llms.txt&lt;/th&gt;
&lt;th&gt;agents.md&lt;/th&gt;
&lt;th&gt;MCP server&lt;/th&gt;
&lt;th&gt;Official agent plugin&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Extend&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;yes&lt;/strong&gt; &lt;code&gt;extend-hq/extend-agent-plugin&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Reducto&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mistral&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LlamaCloud&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anthropic&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS Textract&lt;/td&gt;
&lt;td&gt;org wide only&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;llms.txt&lt;/code&gt; alone is not that special anymore. Six of the seven have one.&lt;/p&gt;

&lt;p&gt;Extend &lt;a href="https://docs.extend.ai/agents.md" rel="noopener noreferrer"&gt;agents.md&lt;/a&gt; had all four. Reducto had three.&lt;/p&gt;

&lt;p&gt;AWS has a general AWS &lt;code&gt;llms.txt&lt;/code&gt;, but nothing specific for Textract.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs3gda4kh8j507xpyham4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs3gda4kh8j507xpyham4.png" alt="extend agents.md file" width="800" height="129"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What actually helped
&lt;/h3&gt;

&lt;p&gt;Extend &lt;code&gt;agents.md&lt;/code&gt; had one small line that saved real time. It says that REST paths and SDK methods can have different names.&lt;/p&gt;

&lt;p&gt;For example&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /extract_runs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;maps to&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;extract_runs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That sounds tiny. But it stopped the exact guess and check loop I hit with another SDK.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 Agent docs are useful when they answer the weird little questions a coding agent would otherwise guess.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Things I got wrong
&lt;/h2&gt;

&lt;p&gt;This benchmark also caught one annoying problem with the datasets themselves. Published ground truth can be wrong too.&lt;/p&gt;

&lt;p&gt;The SROIE answer key says the receipt postcode is &lt;code&gt;B1750&lt;/code&gt;. The receipt clearly says &lt;code&gt;81750&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;FUNSD has &lt;code&gt;Macket&lt;/code&gt; where the form says &lt;code&gt;Market&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So if I blindly used the dataset labels, I would punish a provider for reading the document correctly.&lt;/p&gt;

&lt;p&gt;Nice.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Finb5recb4m9b7gzadlhy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Finb5recb4m9b7gzadlhy.png" alt="incorrect label" width="618" height="120"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is another reason I kept the raw files and responses. Benchmarks can be wrong in boring ways.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one should you pick
&lt;/h2&gt;

&lt;p&gt;There is no one winner for every workflow.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pick Claude directly when
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;You want the highest raw field accuracy from this test&lt;/li&gt;
&lt;li&gt;A human will review the result&lt;/li&gt;
&lt;li&gt;You do not need field citations or confidence scores&lt;/li&gt;
&lt;li&gt;You already use the Anthropic API&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Pick GPT directly when
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Cost matters a lot&lt;/li&gt;
&lt;li&gt;You still want very strong raw accuracy&lt;/li&gt;
&lt;li&gt;A human can check the weird cases&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Pick Extend when
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;A made up value is worse than a missing one&lt;/li&gt;
&lt;li&gt;You need field citations&lt;/li&gt;
&lt;li&gt;You need confidence scores&lt;/li&gt;
&lt;li&gt;You want a review UI&lt;/li&gt;
&lt;li&gt;The extracted data goes into a real production workflow&lt;/li&gt;
&lt;li&gt;Your team uses coding agents and good agent docs matter&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Pick Reducto when
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;You want document specific tooling with strong raw accuracy&lt;/li&gt;
&lt;li&gt;You want zero hallucinations in this test&lt;/li&gt;
&lt;li&gt;You have checked the extraction pricing for your own workload&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Pick Mistral when
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Speed is the main thing you care about&lt;/li&gt;
&lt;li&gt;A human checks the result before anything acts on it&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Textract
&lt;/h3&gt;

&lt;p&gt;For this exact setup, Textract was hard to justify. It cost the most, needed an extra model step, hallucinated twice, and dropped leading digits from money.&lt;/p&gt;

&lt;p&gt;Your workload may be different. But based on these 11 documents, I would test the other options first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;

&lt;p&gt;Everything is in the repo: &lt;a href="https://github.com/shricodev/document-parsing-api-benchmark" rel="noopener noreferrer"&gt;shricodev/document-parsing-api-benchmark&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv &lt;span class="nb"&gt;sync
&lt;/span&gt;uv run bench doctor
uv run bench run &lt;span class="nt"&gt;--all&lt;/span&gt;
uv run bench score
uv run bench report
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;bench doctor&lt;/code&gt; checks credentials, corpus hashes, and spend limits. &lt;code&gt;bench run&lt;/code&gt; will not call an API again if a successful result is already saved.&lt;/p&gt;

&lt;p&gt;So if the run stops halfway through, you can resume without paying for the same documents twice.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;uv run bench run &lt;span class="nt"&gt;--provider&lt;/span&gt; extend &lt;span class="nt"&gt;--doc&lt;/span&gt; 06_bank_statement
extend / 06_bank_statement  cached &lt;span class="o"&gt;(&lt;/span&gt;runs/extend/06_bank_statement/default.json&lt;span class="o"&gt;)&lt;/span&gt;  skipping
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every raw response lives in &lt;code&gt;runs/&lt;/code&gt;. That means every result in this post can be scored again without an API key.&lt;/p&gt;

&lt;p&gt;The source PDFs are not committed because of licensing. &lt;code&gt;corpus/build/fetch.py&lt;/code&gt; downloads and freezes them instead.&lt;/p&gt;

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

&lt;p&gt;The result was closer than I expected.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbire3v31qv199q58ksdj.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbire3v31qv199q58ksdj.gif" alt="close result gif" width="326" height="234"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Claude had the best raw field accuracy. GPT was extremely cheap for how well it scored. Reducto was right behind them.&lt;/p&gt;

&lt;p&gt;And Extend had the cleanest safety result in the group. Zero hallucinations. Zero missed table rows. Its SDK path worked on the first try too.&lt;/p&gt;

&lt;p&gt;That is why I would not pick one of these from the accuracy column alone.&lt;/p&gt;

&lt;p&gt;If a human is reading the output, calling Claude or GPT directly can make a lot of sense. If the JSON is going straight into AP, claims, finance, or another production system, I care much more about citations, confidence, review, and what happens when the model is not sure.&lt;/p&gt;

&lt;p&gt;That is where Extend made the strongest case in this test.&lt;/p&gt;

&lt;p&gt;And yeah, run your own benchmark. Freeze the files, save the responses, and add a few nasty cases.&lt;/p&gt;

&lt;p&gt;This one cost me $9.67. It changed what I would actually use in production. ✅&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>agents</category>
      <category>programming</category>
    </item>
    <item>
      <title>DeepSeek Harness (DSH) vs Pi Agent: Everything you need to know</title>
      <dc:creator>Shrijal Acharya</dc:creator>
      <pubDate>Wed, 09 Sep 2026 12:45:00 +0000</pubDate>
      <link>https://dev.to/composiodev/deepseek-harness-dsh-vs-pi-agent-everything-you-need-to-know-5bci</link>
      <guid>https://dev.to/composiodev/deepseek-harness-dsh-vs-pi-agent-everything-you-need-to-know-5bci</guid>
      <description>&lt;p&gt;DeepSeek released its own agent harness this August.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F94hlogr3p76zinppkpo6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F94hlogr3p76zinppkpo6.png" alt="deepseek harness launch tweet" width="800" height="135"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It crossed &lt;strong&gt;66k stars in about a day (yes, sir!)&lt;/strong&gt; and went over 200k by early September. That is already pretty crazy for something that is still a developer preview.&lt;/p&gt;

&lt;p&gt;But while researching this post, I found something much more interesting. DeepSeek Harness actually uses Pi's model layer to connect to models outside DeepSeek. Yep.&lt;/p&gt;

&lt;p&gt;The new agent runtime from DeepSeek uses Pi under the hood for part of its model support. And &lt;a href="https://github.com/tianyicui" rel="noopener noreferrer"&gt;Tianyi Cui&lt;/a&gt;, who leads the Harness project, has publicly said Pi is a favourite daily driver for many people at DeepSeek. Lol.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flr8ymp1ljxib8xmw8r6x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flr8ymp1ljxib8xmw8r6x.png" alt="dsh praise" width="800" height="538"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That makes this comparison way more fun because they agree on quite a lot. Both think the harness matters, both are open source, both let you change a lot of the runtime, and both try to avoid locking you into one model.&lt;/p&gt;

&lt;p&gt;But they take the idea in very different directions. Pi gives you a tiny coding agent with four tools and lets you build from there. DeepSeek Harness gives you a full agent runtime where even the agent loop itself can be swapped.&lt;/p&gt;

&lt;p&gt;This is going to be an interesting one. Stick to it!&lt;/p&gt;




&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Pi&lt;/th&gt;
&lt;th&gt;DeepSeek Harness&lt;/th&gt;
&lt;th&gt;Winner&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Real tool use in our eval&lt;/td&gt;
&lt;td&gt;21/30 passed&lt;/td&gt;
&lt;td&gt;20/30 passed&lt;/td&gt;
&lt;td&gt;Pi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost per shared success&lt;/td&gt;
&lt;td&gt;$0.031&lt;/td&gt;
&lt;td&gt;$0.028&lt;/td&gt;
&lt;td&gt;DeepSeek Harness&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Median time per task&lt;/td&gt;
&lt;td&gt;362.9s&lt;/td&gt;
&lt;td&gt;252.1s&lt;/td&gt;
&lt;td&gt;DeepSeek Harness&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Avg runtime tokens in our run&lt;/td&gt;
&lt;td&gt;924,990&lt;/td&gt;
&lt;td&gt;88,562, with a catch&lt;/td&gt;
&lt;td&gt;DeepSeek Harness&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Simplicity&lt;/td&gt;
&lt;td&gt;Four main tools and a tiny prompt&lt;/td&gt;
&lt;td&gt;Much larger runtime with 53 built in tools&lt;/td&gt;
&lt;td&gt;Pi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deep runtime control&lt;/td&gt;
&lt;td&gt;Powerful TypeScript extensions around a small core&lt;/td&gt;
&lt;td&gt;Even the agent loop can be replaced&lt;/td&gt;
&lt;td&gt;DeepSeek Harness&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model support&lt;/td&gt;
&lt;td&gt;25+ providers and very easy switching&lt;/td&gt;
&lt;td&gt;Broad support, partly through Pi's model layer&lt;/td&gt;
&lt;td&gt;Pi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local models&lt;/td&gt;
&lt;td&gt;Ollama, vLLM, and custom providers are well supported&lt;/td&gt;
&lt;td&gt;Possible, but needs more setup&lt;/td&gt;
&lt;td&gt;Pi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sandbox&lt;/td&gt;
&lt;td&gt;None in the core&lt;/td&gt;
&lt;td&gt;Three built in sandbox modes&lt;/td&gt;
&lt;td&gt;DeepSeek Harness&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session inspection&lt;/td&gt;
&lt;td&gt;Very readable session tree&lt;/td&gt;
&lt;td&gt;Full append only trajectory and replay&lt;/td&gt;
&lt;td&gt;DeepSeek Harness&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Daily use&lt;/td&gt;
&lt;td&gt;Very small and easy to run&lt;/td&gt;
&lt;td&gt;Web UI and more setup&lt;/td&gt;
&lt;td&gt;Pi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maturity&lt;/td&gt;
&lt;td&gt;Mature enough for normal daily work&lt;/td&gt;
&lt;td&gt;Still a developer preview&lt;/td&gt;
&lt;td&gt;Pi&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  In summary
&lt;/h3&gt;

&lt;p&gt;Scorecard: Pi 6, DeepSeek Harness 6. Dead tie.&lt;/p&gt;

&lt;p&gt;And the benchmark is basically a tie too. Pi passed 21 of 30 tasks and DeepSeek Harness passed 20. DeepSeek Harness was cheaper on the shared successful tasks and also finished tasks faster.&lt;/p&gt;

&lt;p&gt;But there is an important catch. DeepSeek Harness ran through DeepSeek's own endpoint. Pi ran through OpenRouter. So I would trust the pass rate and cost comparison more than I would trust a clean 10x token claim.&lt;/p&gt;

&lt;p&gt;Still, the overall result is pretty clear. Pi is the better choice if you want a coding agent you can install, understand, modify, and use every day.&lt;/p&gt;




&lt;h2&gt;
  
  
  What is DeepSeek Harness?
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;ℹ️ DeepSeek's open source agent runtime where almost every part of the agent can be replaced as a plugin.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbw3ps28nmlzhfw9e277o.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbw3ps28nmlzhfw9e277o.jpg" alt="dsh" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/deepseek-ai/deepseek-harness" rel="noopener noreferrer"&gt;DeepSeek Harness&lt;/a&gt;, or &lt;code&gt;dsh&lt;/code&gt;, is DeepSeek's official agent harness. DeepSeek released it on August 13, 2026 alongside DeepSeek V4 Pro.&lt;/p&gt;

&lt;p&gt;And the basic idea is simple:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agent = Model + Harness&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The model does the thinking. The harness handles everything around it. It gives the model tools, builds the prompt, runs shell commands, manages files, stores sessions, handles context, runs the loop, and decides how all these pieces connect.&lt;/p&gt;

&lt;p&gt;That part is normal. Claude Code, Codex, Pi, OpenCode, and basically every coding agent already has some version of this.&lt;/p&gt;

&lt;p&gt;DeepSeek did something different with how it built the runtime. It uses a plugin system called Cordis, and almost everything runs as a plugin. The model adapter is a plugin, the tools are plugins, the sandbox is a plugin, the session system uses plugins, and even the main agent loop can be replaced.&lt;/p&gt;

&lt;p&gt;Cordis also gives plugins a proper lifecycle. You can mount them from config, reload them, and clean them up when they are removed. So this is much closer to building an agent runtime from replaceable parts than adding a few extensions to a coding CLI.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💁 DeepSeek's own docs basically say there is no special core that you need to patch.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It also ships several different modes. Standard gives you the full agent setup. Code mode, also called PTC, gives the model a generated TypeScript SDK for its tools. So instead of making lots of separate tool calls, the model can write one small program that calls those tools and handles the intermediate data itself.&lt;/p&gt;

&lt;p&gt;Minimal gives it basically shell plus a file editor. Creator lets the &lt;strong&gt;agent inspect and modify parts of its own runtime. 🤯&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkx6rzry7ldc6oetjto34.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkx6rzry7ldc6oetjto34.gif" alt="robot meme" width="480" height="264"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Yes, that last one is as weird as it sounds. It is very different from most coding agents.&lt;/p&gt;

&lt;p&gt;It also comes with 53 built in tools, MCP support, subagents, LSP, web search, scheduling, sandboxing, and a local browser UI. So despite the plugin first design, this is not a tiny framework with nothing included.&lt;/p&gt;




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

&lt;blockquote&gt;
&lt;p&gt;ℹ️ A minimal terminal coding agent that gives the model a few tools and expects you to build whatever else you want.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4w4vw92ejusbx5wh4esp.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4w4vw92ejusbx5wh4esp.jpg" alt="Pi agent" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You probably know Pi by now if you have read the other posts in this series. Mario Zechner built it after getting tired of how much stuff Claude Code kept adding.&lt;/p&gt;

&lt;p&gt;Pi goes almost completely in the other direction. The model gets &lt;code&gt;read&lt;/code&gt;, &lt;code&gt;write&lt;/code&gt;, &lt;code&gt;edit&lt;/code&gt;, and &lt;code&gt;bash&lt;/code&gt;. The system prompt stays under roughly 1,000 tokens. And that is most of the product.&lt;/p&gt;

&lt;p&gt;Need grep? Use bash. Need Git? Use bash. Need another tool? Write a TypeScript extension. Need a whole new workflow? Ask Pi to build the extension for you.&lt;/p&gt;

&lt;p&gt;The project now lives under &lt;a href="https://github.com/earendil-works/pi" rel="noopener noreferrer"&gt;Earendil&lt;/a&gt;, has around 100k GitHub stars, and is still open source and MIT licensed.&lt;/p&gt;

&lt;p&gt;The interesting part is that Pi is not small because nobody built more features yet. It is small because that is the actual design. Every extra tool schema and every extra instruction costs tokens on future model calls, so Pi tries to keep the default harness small.&lt;/p&gt;

&lt;p&gt;DeepSeek Harness "kinda" agrees with some of this, but there's a whole lot to it.&lt;/p&gt;

&lt;p&gt;Related: &lt;a href="https://composio.dev/content/pi-agent-vs-claude-code" rel="noopener noreferrer"&gt;Pi vs Claude Code&lt;/a&gt;, &lt;a href="https://composio.dev/content/pi-vs-opencode" rel="noopener noreferrer"&gt;Pi vs OpenCode&lt;/a&gt;, &lt;a href="https://composio.dev/content/pi-vs-omp" rel="noopener noreferrer"&gt;Pi vs OMP&lt;/a&gt;, &lt;a href="https://composio.dev/content/pi-agent-vs-codex" rel="noopener noreferrer"&gt;Pi vs Codex&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Philosophy: Pi vs DeepSeek Harness
&lt;/h2&gt;

&lt;p&gt;This is probably the most interesting part of the whole comparison because at first they look very similar. Both are open source, both are written mostly in TypeScript, both have the concept of extensions, and both want the model layer to stay flexible.&lt;/p&gt;

&lt;p&gt;And DeepSeek Harness literally uses Pi's model layer for its broad provider support.&lt;/p&gt;

&lt;p&gt;But they disagree on what the starting point should look like.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pi starts tiny
&lt;/h3&gt;

&lt;p&gt;Pi has a small core agent loop. That loop stays pretty simple and extensions can change almost everything around it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff2f2uskxptl9oy67s6l7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff2f2uskxptl9oy67s6l7.png" alt="pi core" width="800" height="729"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The idea is that the model already knows how to code. So give it file access, shell access, editing, and enough freedom to work. Then stop adding stuff unless the user actually needs it.&lt;/p&gt;

&lt;h3&gt;
  
  
  DeepSeek Harness starts from replaceable parts
&lt;/h3&gt;

&lt;p&gt;It splits the runtime into parts and lets you swap those parts through Cordis. Pi gives you a small core and says, build around this. DeepSeek Harness gives you a way to replace its parts too.&lt;/p&gt;

&lt;p&gt;Even the actual loop works differently from an extension point of view. DeepSeek Harness fires events through each turn, such as &lt;code&gt;turn/start&lt;/code&gt;, &lt;code&gt;system-prompt/assemble&lt;/code&gt;, &lt;code&gt;tools/pre-execute&lt;/code&gt;, and &lt;code&gt;turn/end&lt;/code&gt;. A plugin can hook into these points, change messages, add behaviour, or stop a tool before it runs.&lt;/p&gt;

&lt;p&gt;That is a lot deeper than adding another command or tool.&lt;/p&gt;

&lt;p&gt;That sounds like a small difference. It is actually the whole comparison.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💁 Pi is easier to understand. DeepSeek Harness gives you deeper control (&lt;strong&gt;Deep&lt;/strong&gt;Seek gives you &lt;strong&gt;deep&lt;/strong&gt;er control, what a nice rhyme to the name itself 🫡).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There is another interesting part here. DeepSeek used its own Minimal mode for its public V4 coding benchmarks. Minimal mode is basically shell, a file editor, and a tiny system prompt.&lt;/p&gt;

&lt;p&gt;So even DeepSeek's own benchmark setup gets pretty close to the same minimal idea Pi has been pushing from the start.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Architecture: four tools vs basically the whole runtime
&lt;/h2&gt;

&lt;p&gt;Underneath, both still do the normal agent thing. You send a task, the model responds, it calls tools, the harness runs them, the results go back to the model, and this repeats until the model finishes.&lt;/p&gt;

&lt;p&gt;The difference is everything around that loop.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pi Agent
&lt;/h3&gt;

&lt;p&gt;Pi runs as one small process. It has the model layer, the agent loop, the terminal UI, session handling, and the extension system.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F69ohu7sng0ofdzyix116.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F69ohu7sng0ofdzyix116.png" alt="Pi agent architecture" width="800" height="933"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Its session format is also pretty neat. Messages form a tree (&lt;code&gt;/tree&lt;/code&gt; slash command), so you can jump back to an old point and continue in another direction without throwing away the old path. And because the core is small, you can actually open the source and follow what is happening.&lt;/p&gt;

&lt;p&gt;It is one of Pi's best features, at least for me.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftztrgewzifvzrjj3cpl5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftztrgewzifvzrjj3cpl5.png" alt="pi /tree praise" width="800" height="142"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Pi extensions are also just TypeScript files. You can put them inside &lt;code&gt;~/.pi/agent/extensions/&lt;/code&gt; and use them to add tools, block calls, change context, replace compaction behaviour, or add your own TUI stuff.&lt;/p&gt;

&lt;p&gt;That is a very simple way to extend a coding agent because you do not need to understand a big plugin runtime first.&lt;/p&gt;

&lt;h3&gt;
  
  
  DeepSeek Harness
&lt;/h3&gt;

&lt;p&gt;DeepSeek Harness is much bigger, and it does much more. It has a full plugin graph, sandboxing, subagents, MCP, scheduling, compaction, a browser UI, an append only session log, replay, and much more.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm9ypg2bufdwscf8ujg08.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm9ypg2bufdwscf8ujg08.png" alt="dsh architecture" width="800" height="918"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The session system is probably my favourite part. Every model visible event gets logged. So if the model saw something, there should be a record of it. You can inspect the trajectory later and see what happened, or replay and fork sessions from the stored events.&lt;/p&gt;

&lt;p&gt;There is a technical reason for doing this too. DeepSeek Harness does not go back and rewrite old session history. It keeps appending new events instead.&lt;/p&gt;

&lt;p&gt;That makes it easier to keep the beginning of the model context stable, which is useful for DeepSeek's prefix caching.&lt;/p&gt;

&lt;p&gt;That is very useful if you are building agent infrastructure.&lt;/p&gt;

&lt;p&gt;I would take Pi for understanding my own coding session. I would take DeepSeek Harness if I were building a system that runs thousands of agent sessions and I needed to inspect exactly what happened.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. The benchmark: same model, different harness
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjiaz0mg0ntpt7ur2ojc5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjiaz0mg0ntpt7ur2ojc5.png" alt="bench summary" width="800" height="522"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the part I was most curious about. We ran 30 hard agent tool use tasks across the harnesses.&lt;/p&gt;

&lt;p&gt;The agent has to work with real apps and real data through tools, and then our verifier checks whether it actually completed the job. Each task had a 900 second cap.&lt;/p&gt;

&lt;p&gt;For this run, we used DeepSeek V4 Pro.&lt;/p&gt;

&lt;p&gt;Here is what happened:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Harness&lt;/th&gt;
&lt;th&gt;Tasks passed&lt;/th&gt;
&lt;th&gt;Median time&lt;/th&gt;
&lt;th&gt;Avg runtime tokens&lt;/th&gt;
&lt;th&gt;Cost per shared success&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pi&lt;/td&gt;
&lt;td&gt;21/30&lt;/td&gt;
&lt;td&gt;362.9s&lt;/td&gt;
&lt;td&gt;924,990&lt;/td&gt;
&lt;td&gt;$0.031&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek Harness&lt;/td&gt;
&lt;td&gt;20/30&lt;/td&gt;
&lt;td&gt;252.1s&lt;/td&gt;
&lt;td&gt;88,562&lt;/td&gt;
&lt;td&gt;$0.028&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Pi won by one task. That is basically a tie on quality. Pi passed &lt;strong&gt;21/30 tasks&lt;/strong&gt;, while DeepSeek Harness passed &lt;strong&gt;20/30&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fepei1dnqdka0hcl5ymdr.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fepei1dnqdka0hcl5ymdr.jpg" alt="pass rate of harnesses" width="679" height="381"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And that matters because DeepSeek built Harness around its own model. You might expect the first party harness to have some huge advantage. It did not. Pi actually finished one more task.&lt;/p&gt;

&lt;p&gt;But pass rate is only one part of the story.&lt;/p&gt;

&lt;p&gt;DeepSeek Harness also came out slightly cheaper. It cost around &lt;strong&gt;$0.028 per shared success&lt;/strong&gt;, compared to &lt;strong&gt;$0.031 for Pi&lt;/strong&gt;. OpenCode was very close at $0.032, while Claude Code came out much higher at $0.074.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fknkuee7x7c3ylkhqmsq4.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fknkuee7x7c3ylkhqmsq4.jpg" alt="cost per success comparison of all harnesses" width="679" height="381"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The difference gets more interesting when you look at speed.&lt;/p&gt;

&lt;p&gt;DeepSeek Harness had a median time of &lt;strong&gt;252.1 seconds per task&lt;/strong&gt;, while Pi took &lt;strong&gt;362.9 seconds&lt;/strong&gt;. Claude Code was the fastest at 181.8 seconds, but it also cost much more per successful task.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm2iytmz1dkiszqypp1hh.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm2iytmz1dkiszqypp1hh.jpg" alt="median time of harnesses" width="680" height="379"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So DSH did not beat Pi on task success, but it was faster and slightly cheaper in this run.&lt;/p&gt;

&lt;p&gt;Then look at the runtime token column. That number is wild.&lt;/p&gt;

&lt;p&gt;DeepSeek Harness recorded about &lt;strong&gt;88k runtime tokens per task&lt;/strong&gt;. Pi recorded about &lt;strong&gt;925k&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Again, there is a provider routing catch here, and I will get into it in a second. But DSH clearly ran DeepSeek V4 Pro very efficiently on its native route.&lt;/p&gt;

&lt;h3&gt;
  
  
  The task split is also interesting
&lt;/h3&gt;

&lt;p&gt;Most of the tasks ended the same way for Pi and DSH. Pi and DeepSeek Harness agreed on 27 of the 30 outcomes. The entire 21 vs 20 difference came down to three tasks.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Folx429e1ly2j464nzrwg.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Folx429e1ly2j464nzrwg.jpg" alt="task split" width="680" height="368"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Pi passed the CRM migration archive task while DeepSeek Harness failed it. Pi also passed the handover audit while DeepSeek Harness failed that one. DeepSeek Harness got the Sheets to Airtable tracker right while Pi failed it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Pi&lt;/th&gt;
&lt;th&gt;DeepSeek Harness&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CRM migration archive&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Handover audit&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sheets to Airtable tracker&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The bigger chart is useful because it shows that this did not only happen between Pi and DSH. The same DeepSeek model changed behaviour when we moved it between Claude Code, OpenCode, Hermes, Pi, and DeepSeek Harness.&lt;/p&gt;

&lt;p&gt;For example, Claude Code cleared every check on the renewal screen task while Pi and DSH only cleared 5 of 11. On Slack action items, Pi, DSH, Claude Code, and Hermes all passed, while OpenCode only cleared 2 of 5 checks.&lt;/p&gt;

&lt;p&gt;So we did not see one harness destroying the others across every kind of work. The same model behaved differently depending on the harness around it.&lt;/p&gt;

&lt;p&gt;And I think that is the important result. The harness changed efficiency a lot. It changed speed. It changed cost. And on some tasks, it changed whether the model actually finished the job.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💁 Same model. Almost the same pass rate. Very different runtime behaviour.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  So did DeepSeek Harness really use 10x fewer tokens?
&lt;/h3&gt;

&lt;p&gt;The raw table makes it look like Pi is eating up tokens, but that is not really the full story.&lt;/p&gt;

&lt;p&gt;DeepSeek Harness used DeepSeek's official endpoint, while Pi used OpenRouter. That changes how caching and token usage get reported.&lt;/p&gt;

&lt;p&gt;Pi also has a much smaller base prompt. One separate comparison measured around &lt;strong&gt;4.5k uncached input for Pi&lt;/strong&gt; against &lt;strong&gt;47.6k for DSH&lt;/strong&gt; on the first turn. Pi keeps the prefix tiny, while DSH keeps a much larger prefix stable and gets strong cache reuse from it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💁 They are solving the same token problem in different ways.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And once you look at actual cost per success, the gap is tiny: &lt;strong&gt;$0.028 for DSH vs $0.031 for Pi&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So DSH was very efficient in our run, but I would not treat the 88k vs 925k token number as a clean 10x win.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. DeepSeek Harness uses Pi
&lt;/h2&gt;

&lt;p&gt;This still makes me laugh. 🥴&lt;/p&gt;

&lt;p&gt;For DeepSeek models, DSH has its own model adapter. Makes sense. That adapter understands things like DeepSeek reasoning levels and cache usage.&lt;/p&gt;

&lt;p&gt;But when it needs other model support, it can use an adapter built on Pi's &lt;code&gt;pi-ai&lt;/code&gt; layer.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftaj3ow1vffc4ieewemhl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftaj3ow1vffc4ieewemhl.png" alt="dsh uses pi under the hood" width="800" height="286"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So DSH can connect to Anthropic, OpenAI, Gemini, Bedrock, Vertex, Azure, Codex, and other providers through the model work Pi already did.&lt;/p&gt;

&lt;p&gt;That tells you quite a lot about Pi. It looks like a tiny terminal coding agent from the outside, but its model layer has become super useful infrastructure on its own.&lt;/p&gt;

&lt;p&gt;DeepSeek could have written another provider abstraction. It reused Pi's.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💁 I would probably do the same thing. Why not?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Pi still wins on model flexibility
&lt;/h3&gt;

&lt;p&gt;Pi has had much more time to build this part out. It works with 25 plus providers, local models, OpenRouter, Ollama, vLLM, cloud providers, and custom compatible endpoints. You can also switch models during a session.&lt;/p&gt;

&lt;p&gt;DeepSeek Harness can work with many of the same providers, but the whole product still feels very "DeepSeek first." And that is fine.&lt;/p&gt;

&lt;p&gt;If you mainly use V4 Pro or V4 Flash, DSH has a real advantage because its native DeepSeek adapter understands the provider directly.&lt;/p&gt;

&lt;p&gt;If you change models often, run local models, or use different cloud providers, Pi is easier.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Extensibility
&lt;/h2&gt;

&lt;p&gt;Pi is one of the most hackable coding agents I have used. Its extensions run in TypeScript inside the same process. They can change tools, add UI, inject context, block tool calls, store custom state, and so much more...&lt;/p&gt;

&lt;p&gt;And because the codebase is small, Pi can often write its own extension when you ask it to. That is still very cool.&lt;/p&gt;

&lt;p&gt;A basic Pi extension can just live in &lt;code&gt;~/.pi/agent/extensions/&lt;/code&gt;. You do not need to learn a separate framework before adding one tool or one hook.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6qhcjpt5xrz9ids0jvo8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6qhcjpt5xrz9ids0jvo8.png" alt="dsh plugins" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;But DeepSeek Harness goes further. With DSH, the loop itself can be a plugin. So can the sandbox, model handling, context management, and a lot of the runtime around them.&lt;/p&gt;

&lt;p&gt;A Cordis plugin can join the runtime's dependency graph, expose a service, listen to agent events, and clean itself up when it gets removed. That is more work for a simple extension, but it gives you much deeper control.&lt;/p&gt;

&lt;p&gt;The agent can even inspect its own plugin tree and, in Creator mode, experiment with runtime changes. That is more control. It is also more stuff to understand and tbh, most of it is something I don't know at all.&lt;/p&gt;

&lt;p&gt;If you are researching agent loops, building your own agent product, testing new context systems, or replacing parts of the runtime itself, DSH gives you way more room.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💁 Pi is a lot easier to extend. DSH is the deeper thing to change.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  6. Safety and Security
&lt;/h2&gt;

&lt;p&gt;Pi is chill about permissions. The agent gets your user permissions and can run commands. There is no big built in approval system protecting you from every shell command.&lt;/p&gt;

&lt;p&gt;You can add safety through extensions or run Pi inside a container or VM, but the core itself does not try to do much here.&lt;/p&gt;

&lt;p&gt;DeepSeek Harness comes with actual sandbox modes. It can run read only, it can allow writes inside the workspace, or you can give it full access.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F04z7366ipo5zbsesh5sb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F04z7366ipo5zbsesh5sb.png" alt="dsh vs pi safety and security" width="800" height="582"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And this is not just a UI permission setting. On Linux, DSH can use &lt;code&gt;bwrap&lt;/code&gt; and Landlock. On macOS, it uses Seatbelt. Windows uses a restricted token model.&lt;/p&gt;

&lt;p&gt;The sandbox also tries to fail closed. If DSH expects a sandbox and cannot create one, it should not silently continue with full access.&lt;/p&gt;

&lt;p&gt;That is a real DSH win.&lt;/p&gt;

&lt;p&gt;There are two catches though.&lt;/p&gt;

&lt;p&gt;The sandbox controls tool execution, but plugins themselves run inside the harness process. So if you install a random third party plugin, you are still trusting that code with serious access to your machine.&lt;/p&gt;

&lt;p&gt;And DeepSeek itself says the sandbox has not been security audited yet.&lt;/p&gt;

&lt;p&gt;So I would still be careful with unattended runs, but DSH gives you much stronger safety defaults than Pi.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. The biggest DSH problem right now is maturity
&lt;/h2&gt;

&lt;p&gt;This is where the comparison stops being close for me.&lt;/p&gt;

&lt;p&gt;Pi has been around for more than a year. People use it every day. OpenClaw runs on it. And the core is stable enough that people build real workflows around it.&lt;/p&gt;

&lt;p&gt;DeepSeek Harness launched in August. It is still a developer preview and warns that &lt;strong&gt;compatibility breaking changes will happen&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And those changes are actually happening. The project pushed several alpha versions within days, and community reports have already found plugin breaks and many more issues.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fakos03znakshv0b7k4jc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fakos03znakshv0b7k4jc.png" alt="dsh issues in the production build" width="799" height="383"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There is also one governance thing worth knowing. GitHub Issues are disabled right now, and DeepSeek is not accepting outside pull requests yet. Feedback mainly goes through Discussions and Discord.&lt;/p&gt;

&lt;p&gt;That does not make the project bad, especially this early. But if I am deciding which open source agent I want to build on today, that matters.&lt;/p&gt;

&lt;p&gt;This is normal for a project this new, but it changes who I would recommend it to.&lt;/p&gt;

&lt;p&gt;I would happily use DSH to experiment with agent architecture today. I would not build an important production workflow around a runtime that is still telling me its interfaces can break.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. Daily use
&lt;/h2&gt;

&lt;p&gt;This one is pretty simple.&lt;/p&gt;

&lt;p&gt;Pi is a pretty regular agent that opens in the terminal. You pick a model and start working. The whole thing feels small because &lt;strong&gt;it is small&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;DeepSeek Harness starts a local web app by default. You run it and open the UI in your browser. The official command is basically one &lt;code&gt;npx&lt;/code&gt; call, so installation itself is not hard.&lt;/p&gt;

&lt;p&gt;But once you start changing profiles, plugins, providers, Cordis config, sandbox settings, and runtime parts, there is much more to learn.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi5r3napp6wuotbw6reet.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi5r3napp6wuotbw6reet.gif" alt="take notes" width="480" height="272"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Pi can get you to a useful coding task in a few minutes. DSH needs more setup and its docs make much more sense once you already understand agent infrastructure.&lt;/p&gt;

&lt;p&gt;That is also why I do not really see DSH replacing Pi for normal users right now. It solves a bigger problem, and bigger does not automatically mean better. 🤷‍♂️&lt;/p&gt;




&lt;h2&gt;
  
  
  Which one should you pick?
&lt;/h2&gt;

&lt;p&gt;Pick Pi if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You mainly want a coding agent&lt;/li&gt;
&lt;li&gt;You want something small enough to understand&lt;/li&gt;
&lt;li&gt;You use many different model providers&lt;/li&gt;
&lt;li&gt;You run local models&lt;/li&gt;
&lt;li&gt;You care about keeping the fixed prompt tiny&lt;/li&gt;
&lt;li&gt;You want a clean TypeScript extension system&lt;/li&gt;
&lt;li&gt;You want something mature enough to use every day&lt;/li&gt;
&lt;li&gt;You would rather add the few features you need than start with 50 tools&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pick DeepSeek Harness if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You are building agent infrastructure&lt;/li&gt;
&lt;li&gt;You want to replace the agent loop itself&lt;/li&gt;
&lt;li&gt;You need strong session traces and replay&lt;/li&gt;
&lt;li&gt;You want a built in sandbox&lt;/li&gt;
&lt;li&gt;You want MCP, subagents, LSP, and more tooling out of the box&lt;/li&gt;
&lt;li&gt;You mainly use DeepSeek models and want the native integration&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And you can obviously run both. They are free and open source. They even share some code.&lt;/p&gt;




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

&lt;p&gt;The scorecard says 6 to 6. Our benchmark says Pi 21, DeepSeek Harness 20. That is as close as it gets.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2odm54lgfcbl5zb8ouvg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2odm54lgfcbl5zb8ouvg.png" alt="dsh vs pi summary" width="800" height="582"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;But I do not think these two are actually fighting for the same user yet.&lt;/p&gt;

&lt;p&gt;Pi feels like a coding agent made by someone who wanted the tool to stay out of his way. DeepSeek Harness feels like something built by people who want to inspect and change every part of how an agent works. Both are right. It depends on your preference. 💁&lt;/p&gt;

&lt;p&gt;For normal coding, I would pick Pi today. It is smaller, easier to understand, and works with basically anything. It actually passed one more task in our run.&lt;/p&gt;

&lt;p&gt;For agent infrastructure work, DSH is way more interesting. Being able to swap the loop, sandbox, model layer, and other runtime parts without rebuilding the whole agent is genuinely useful.&lt;/p&gt;

&lt;p&gt;And DSH is still very new. It shipped all of this in a developer preview. If DeepSeek can make the runtime stable without losing the parts that make it interesting, I think it is going to be worth watching.&lt;/p&gt;

&lt;p&gt;For now, I just would not treat it like a daily driver yet. DeepSeek itself tells you it is a developer preview.&lt;/p&gt;

&lt;p&gt;So yeah. If I just want to code, I am still picking Pi. ✌️&lt;/p&gt;


&lt;div class="ltag__user ltag__user__id__1127015"&gt;
    &lt;a href="/shricodev" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1127015%2F1c5e48a2-f602-4e7d-8312-3c0322d155c6.jpg" alt="shricodev image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/shricodev"&gt;Shrijal Acharya&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/shricodev"&gt;SDE • GOLD @Microsoft Student Ambassador • Prev Lead Collab and Dev-Team Lead @oppiaorg • Mail for collaboration&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;


</description>
      <category>ai</category>
      <category>productivity</category>
      <category>opensource</category>
      <category>agents</category>
    </item>
    <item>
      <title>Pi Agent vs OpenCode after 100+ Hours of Real Use ✌️</title>
      <dc:creator>Shrijal Acharya</dc:creator>
      <pubDate>Fri, 21 Aug 2026 13:31:28 +0000</pubDate>
      <link>https://dev.to/composiodev/pi-agent-vs-opencode-after-100-hours-of-real-use-1mh7</link>
      <guid>https://dev.to/composiodev/pi-agent-vs-opencode-after-100-hours-of-real-use-1mh7</guid>
      <description>&lt;p&gt;Open-source coding agents had a weird start to 2026.&lt;/p&gt;

&lt;p&gt;In January, Anthropic suddenly blocked third-party tools from using Claude subscriptions. Overnight, with no warning.&lt;/p&gt;

&lt;p&gt;OpenCode got hit the hardest. The team had to remove Claude login support after legal pressure. The commit message was literally just: “anthropic legal requests.”&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8jimv4abmhdeojze1lw5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8jimv4abmhdeojze1lw5.png" alt="OpenCode PR removing anthropic oauth" width="800" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;PI never really cared. It was never built around one provider anyway.&lt;/p&gt;

&lt;p&gt;And that kinda brings us to the obvious question. If you’re using your own API keys anyway, which open-source agent do you actually pick?&lt;/p&gt;

&lt;p&gt;OpenCode is sitting at &lt;strong&gt;199K stars&lt;/strong&gt;, probably past 200K by the time you read this, with around 8 million monthly devs.&lt;/p&gt;

&lt;p&gt;Then there’s Pi. Bare minimum by design. Four tools. A system prompt under 1,000 tokens. And somehow, top of our benchmark. Again.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;ℹ️ We recently covered &lt;a href="https://composio.dev/content/pi-agent-vs-claude-code" rel="noopener noreferrer"&gt;Pi Agent vs. Claude Code&lt;/a&gt;. Check that out if you're not sure whether Anthropic or Pi suits you better&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The easiest way I’d frame it is this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💁 OpenCode is VS Code (bit bloaty). Pi is NeoVim (bare minimum).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So which one do you go for? Let's get into the difference.&lt;/p&gt;




&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Pi&lt;/th&gt;
&lt;th&gt;OpenCode&lt;/th&gt;
&lt;th&gt;Winner&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Real tool use (our eval)&lt;/td&gt;
&lt;td&gt;21/30 tasks passed, $0.078 per success&lt;/td&gt;
&lt;td&gt;19/30 tasks passed, $0.119 per success&lt;/td&gt;
&lt;td&gt;Pi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total eval spend&lt;/td&gt;
&lt;td&gt;$1.64 for all 30 tasks&lt;/td&gt;
&lt;td&gt;$2.25 for all 30 tasks&lt;/td&gt;
&lt;td&gt;Pi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speed&lt;/td&gt;
&lt;td&gt;362.9s median per task, slowest of the bunch&lt;/td&gt;
&lt;td&gt;280.6s median per task&lt;/td&gt;
&lt;td&gt;OpenCode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Context overhead&lt;/td&gt;
&lt;td&gt;Under 1,000 tokens, prompt + tool defs&lt;/td&gt;
&lt;td&gt;~6,900 tokens per request&lt;/td&gt;
&lt;td&gt;Pi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Out-of-the-box features&lt;/td&gt;
&lt;td&gt;Four tools&lt;/td&gt;
&lt;td&gt;Plan mode, MCP, LSP diagnostics, undo/redo, permissions, subagents, and a lot more&lt;/td&gt;
&lt;td&gt;OpenCode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model support&lt;/td&gt;
&lt;td&gt;20+ providers, 300+ models, mid-session switching&lt;/td&gt;
&lt;td&gt;75+ providers via AI SDK and &lt;code&gt;models.dev&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;OpenCode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cheap model access&lt;/td&gt;
&lt;td&gt;BYOK or local, that's it&lt;/td&gt;
&lt;td&gt;Zen gateway at cost, plus Go at $10/month&lt;/td&gt;
&lt;td&gt;OpenCode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extensibility&lt;/td&gt;
&lt;td&gt;TypeScript extensions running inside the agent process&lt;/td&gt;
&lt;td&gt;Plugins, custom agents, skills, mostly config-driven&lt;/td&gt;
&lt;td&gt;Pi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sessions&lt;/td&gt;
&lt;td&gt;Branchable session trees with fork and rewind&lt;/td&gt;
&lt;td&gt;Linear log with undo/redo, but survives disconnects&lt;/td&gt;
&lt;td&gt;Pi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Surfaces&lt;/td&gt;
&lt;td&gt;Terminal&lt;/td&gt;
&lt;td&gt;TUI, desktop app, web UI, IDEs via ACP, GitHub and GitLab CI&lt;/td&gt;
&lt;td&gt;OpenCode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Permissions&lt;/td&gt;
&lt;td&gt;Nothing. Full system access from prompt one, which isn't really my preferred style&lt;/td&gt;
&lt;td&gt;allow/ask/deny per tool with glob patterns&lt;/td&gt;
&lt;td&gt;OpenCode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resource footprint&lt;/td&gt;
&lt;td&gt;Lightweight, single process&lt;/td&gt;
&lt;td&gt;1GB+ RAM for a TUI is kinda crazy&lt;/td&gt;
&lt;td&gt;Pi&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  In summary
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Scorecard: Pi 6, OpenCode 6. Dead tie.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And honestly, that feels right. These two are built for pretty different kinds of people anyway.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Remember the VS Code vs. Neovim thing I said earlier?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Pi is more like a platform you program. You get a tiny core and build your own harness around it in TypeScript.&lt;/p&gt;

&lt;p&gt;OpenCode is more like a product you configure. You get something much closer to a full Claude Code replacement on day one, then tweak it through config.&lt;/p&gt;

&lt;p&gt;Pi wins on efficiency, hackability, and, at least in our eval, raw results.&lt;/p&gt;

&lt;p&gt;OpenCode wins on features, provider support, surfaces, and the simple fact that a normal person can pick it up and start using it without building half the setup themselves.&lt;/p&gt;

&lt;p&gt;So yeah, the real tiebreaker is basically what kind of person you are. Keep reading.&lt;/p&gt;




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

&lt;blockquote&gt;
&lt;p&gt;ℹ️ A minimal terminal coding agent that ships four tools and expects you to build the rest yourself.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0hsa836yiuj6i335nnhm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0hsa836yiuj6i335nnhm.png" alt="pi agent" width="800" height="369"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Quick recap if you missed our &lt;a href="https://composio.dev/blog/pi-agent-vs-claude-code" rel="noopener noreferrer"&gt;Pi vs Claude Code&lt;/a&gt; post.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://pi.dev/" rel="noopener noreferrer"&gt;Pi&lt;/a&gt; is Mario Zechner's answer to harness bloat. He got tired of Claude Code changing under him, so he built an agent where the model gets &lt;code&gt;read&lt;/code&gt;, &lt;code&gt;write&lt;/code&gt;, &lt;code&gt;edit&lt;/code&gt;, and &lt;code&gt;bash&lt;/code&gt;, a system prompt of a few hundred tokens, and nothing else. No MCP. No permissions. No plan mode. No subagents. All on purpose.&lt;/p&gt;

&lt;p&gt;It now lives under &lt;a href="https://github.com/earendil-works/pi" rel="noopener noreferrer"&gt;Earendil&lt;/a&gt; after his April blog post "I've sold out," and the core stays open-source.&lt;/p&gt;

&lt;p&gt;The real product is the extension system. TypeScript files run inside the same process as the agent loop, and Pi can read its own source code. So when you need a missing feature, you literally ask Pi to build it.&lt;/p&gt;

&lt;p&gt;That design is also why Peter Steinberger picked Pi as the engine for OpenClaw.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💁 &lt;strong&gt;Fun Fact:&lt;/strong&gt; Pi has "only" 94k stars, but the npm package pulls around 1.3 million downloads a week. Growing crazy fast among developers.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not everyone is fond of VS Code. 🤷‍♂️&lt;/p&gt;




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

&lt;blockquote&gt;
&lt;p&gt;ℹ️ The most starred open source coding agent in the world, and the closest thing to an open, model agnostic Claude Code.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0pkn6lanxysm87cxsm0z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0pkn6lanxysm87cxsm0z.png" alt="opencode" width="800" height="451"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://opencode.ai" rel="noopener noreferrer"&gt;OpenCode&lt;/a&gt; is basically an open-source, model-agnostic coding agent that tries to give you the full Claude Code experience without locking you to Anthropic&lt;/p&gt;

&lt;p&gt;It is built by the team behind SST, which rebranded to Anomaly in 2026 and moved the repo to &lt;code&gt;anomalyco/opencode&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The numbers are crazy for a free tool. Around &lt;strong&gt;199k stars&lt;/strong&gt;. Roughly 8 million monthly active developers. Cloudflare publishes official OpenCode setup docs for its own engineers.&lt;/p&gt;

&lt;p&gt;Can you believe this free thing is doing over $25M ARR? It's just the tool that's free, there's subscription plans they've introduced from where they make tons of money.&lt;/p&gt;

&lt;p&gt;The origin story is crazier than Pi's. The original agent, called TermAI, was created by Kujtim Hoxha back in 2024. &lt;a href="https://x.com/thdxr" rel="noopener noreferrer"&gt;Dax Raad&lt;/a&gt; and Adam Elmore did the OpenCode rebrand and made it popular. Then Charm hired Hoxha and pulled the repo into its own org. That causes rewritten git history, banned contributors, the whole drama. After the backlash, Charm renamed its fork &lt;strong&gt;Crush&lt;/strong&gt;, and the Dax and Adam project kept the OpenCode name as a from scratch rewrite.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsp1sf9ncds2glip7s2gb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsp1sf9ncds2glip7s2gb.png" alt="opencode origin" width="800" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Not anything interesting if you don't care about "history". Lol.&lt;/p&gt;

&lt;p&gt;So when anyone says OpenCode in 2026, they mean the Anomaly one.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💁 &lt;strong&gt;Fun fact:&lt;/strong&gt; For the 1.0 release, the team threw out their entire Go based TUI and wrote a new framework called OpenTUI in Zig and SolidJS, because the old one "had performance and capability issues." Can't believe Golang had performance issues at that scale.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A coding agent team writing their own Zig rendering engine for the terminal is the most 2026 thing I can think of. Crazy stuff.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Philosophy: Pi vs OpenCode
&lt;/h2&gt;

&lt;p&gt;Our earlier comparisons were open vs closed. This one is open vs open.&lt;/p&gt;

&lt;p&gt;Both teams agree on the big stuff. The harness should be MIT licensed. You should bring your own model. Nobody should be vendor-locked in.&lt;/p&gt;

&lt;p&gt;They completely disagree on how much harness should exist.&lt;/p&gt;

&lt;p&gt;OpenCode's philosophy is that the harness is a real product. Ship plan mode, permissions, LSP integration, MCP, a desktop app, a web UI, IDE support, GitHub actions. Give people everything Claude Code has, minus the lock in.&lt;/p&gt;

&lt;p&gt;Pi's bet is that most of that is overhead (not a feature, what OpenCode believes). Frontier models have been RL trained so hard on coding that they already know what a coding agent is. Every tool schema and every injected instruction is context you pay for on every request. So the harness should be close to zero, and you add back only what you personally need.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/disler/pi-vs-claude-code/blob/main/PI_VS_OPEN_CODE.md" rel="noopener noreferrer"&gt;disler comparison&lt;/a&gt; has the cleanest one liner on this.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Pi gives you control at the runtime level. OpenCode gives you control at the configuration level."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And yes, there is real beef here. Zechner has publicly called out OpenCode's compaction for pruning tool results and breaking prompt caching.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhm8s27cqzhdwh4m2r63d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhm8s27cqzhdwh4m2r63d.png" alt="zechner called out opencode compaction" width="800" height="154"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;He is not a fan of their Vercel AI SDK dependency either. More on that below.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz2ty3niibtgm8d4gd8bq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz2ty3niibtgm8d4gd8bq.png" alt="zechner not a fan of vercel ai sdk" width="800" height="190"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Agent Architecture: Pi vs OpenCode
&lt;/h2&gt;

&lt;p&gt;Underneath, both use the same basic loop: read the task, call the model, run tools, feed results back, repeat.&lt;/p&gt;

&lt;p&gt;The architecture around that loop is where they differ.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv09kkh6jojfuvzgrzb0c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv09kkh6jojfuvzgrzb0c.png" alt="agent architecture difference pi vs opencode" width="800" height="470"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  OpenCode: client + server
&lt;/h3&gt;

&lt;p&gt;OpenCode splits into a persistent local server and separate clients.&lt;/p&gt;

&lt;p&gt;The server handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;agent logic&lt;/li&gt;
&lt;li&gt;LLM calls&lt;/li&gt;
&lt;li&gt;tool execution&lt;/li&gt;
&lt;li&gt;SQLite session storage&lt;/li&gt;
&lt;li&gt;MCP servers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The TUI, desktop app, web UI, and IDE integrations all talk to that same server.&lt;/p&gt;

&lt;p&gt;This makes sessions more safe to terminal crashes or SSH drops, and the same server can also run headlessly in CI. The downside is a heavier footprint, with people reporting &lt;strong&gt;1GB+ RAM usage&lt;/strong&gt; for the TUI setup.&lt;/p&gt;

&lt;p&gt;You read it right. Read more about it here: &lt;a href="https://news.ycombinator.com/item?id=47465584" rel="noopener noreferrer"&gt;OpenCode is pretty resource inefficient&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F20ol6v5g70zqk4jmoqjq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F20ol6v5g70zqk4jmoqjq.png" alt="Opencode 1gb+ ram tui usage" width="800" height="264"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Pi: single process
&lt;/h3&gt;

&lt;p&gt;Pi keeps everything in one process:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;provider layer&lt;/li&gt;
&lt;li&gt;agent loop&lt;/li&gt;
&lt;li&gt;CLI&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is all TypeScript and small enough to understand without going through a huge codebase.&lt;/p&gt;

&lt;p&gt;Pi supports four run modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;interactive TUI&lt;/li&gt;
&lt;li&gt;print mode for scripts&lt;/li&gt;
&lt;li&gt;RPC over stdin/stdout&lt;/li&gt;
&lt;li&gt;SDK mode for embedding&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That SDK is also how OpenClaw embeds Pi.&lt;/p&gt;

&lt;h3&gt;
  
  
  OpenCode's way of LSP
&lt;/h3&gt;

&lt;p&gt;I'd call it a star feature of OpenCode, the way it handles LSP (Language Server Protocol).&lt;/p&gt;

&lt;p&gt;OpenCode can run real language servers such as &lt;code&gt;gopls&lt;/code&gt; and &lt;code&gt;typescript-language-server&lt;/code&gt; and feed diagnostics back into the agent after edits.&lt;/p&gt;

&lt;p&gt;So the flow looks like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;model edits code → LSP reports an error → model sees it → model fixes it&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That gives OpenCode fast compiler and type feedback without needing to run the full test every time.&lt;/p&gt;

&lt;p&gt;Pi does not have this out of the box.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pi's Session trees
&lt;/h3&gt;

&lt;p&gt;Pi way of handling sessions is kinda interesting.&lt;/p&gt;

&lt;p&gt;Pi stores sessions as &lt;code&gt;JSONL&lt;/code&gt;, with every entry pointing to a parent. That means the session history is a tree instead of one linear log.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A → B → C
     ↘ D → E
     ↘ F → G
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;use &lt;code&gt;/tree&lt;/code&gt; to jump back to an earlier point&lt;/li&gt;
&lt;li&gt;use &lt;code&gt;/fork&lt;/code&gt; to branch from any past message&lt;/li&gt;
&lt;li&gt;keep multiple debugging paths from the same point&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;OpenCode uses a linear history with &lt;code&gt;/undo&lt;/code&gt; and &lt;code&gt;/redo&lt;/code&gt;, which is simpler but less flexible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Context compaction
&lt;/h3&gt;

&lt;p&gt;OpenCode uses a hidden system agent to summarize the conversation when context gets tight, then hides older messages from the active context.&lt;/p&gt;

&lt;p&gt;The criticism is that this can remove useful tool outputs and hurt prompt caching.&lt;/p&gt;

&lt;p&gt;Pi also supports compaction, but the implementation is replaceable through extensions, which fits its more hackable architecture.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. The benchmark. Same model, different harness
&lt;/h2&gt;

&lt;p&gt;Now for the part I was most curious about.&lt;/p&gt;

&lt;p&gt;We ran &lt;strong&gt;30 hard agentic tool-use tasks&lt;/strong&gt; against real apps. Every harness used the same model, &lt;strong&gt;DeepSeek V4 Pro (0813)&lt;/strong&gt; at max reasoning, through our hosted MCP router.&lt;/p&gt;

&lt;p&gt;Same model. Same tasks. Same tools. Same verifier checks.&lt;/p&gt;

&lt;p&gt;So whatever gap shows up here is mostly the harness.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzcxk81pd9nlfx0ftssdv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzcxk81pd9nlfx0ftssdv.png" alt="benchmark eval of opencode vs pi agent" width="799" height="488"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here is the full result.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Harness (DeepSeek V4 Pro, hard 30 set)&lt;/th&gt;
&lt;th&gt;Tasks passed&lt;/th&gt;
&lt;th&gt;Cost per success&lt;/th&gt;
&lt;th&gt;Cost per shared success&lt;/th&gt;
&lt;th&gt;Avg tokens per task&lt;/th&gt;
&lt;th&gt;Avg turns&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pi Agent&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;21/30 (70%)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.078&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0.031&lt;/td&gt;
&lt;td&gt;924,990&lt;/td&gt;
&lt;td&gt;16.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Codex&lt;/td&gt;
&lt;td&gt;20/30 (66.7%)&lt;/td&gt;
&lt;td&gt;n/a*&lt;/td&gt;
&lt;td&gt;$0.031&lt;/td&gt;
&lt;td&gt;383,722&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek Harness&lt;/td&gt;
&lt;td&gt;20/30 (66.7%)&lt;/td&gt;
&lt;td&gt;$0.076&lt;/td&gt;
&lt;td&gt;$0.028&lt;/td&gt;
&lt;td&gt;88,562&lt;/td&gt;
&lt;td&gt;0.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OpenCode&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;19/30 (63.3%)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.119&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0.032&lt;/td&gt;
&lt;td&gt;710,140&lt;/td&gt;
&lt;td&gt;13.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;td&gt;19/30 (63.3%)&lt;/td&gt;
&lt;td&gt;n/a*&lt;/td&gt;
&lt;td&gt;$0.074&lt;/td&gt;
&lt;td&gt;649,900&lt;/td&gt;
&lt;td&gt;12.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hermes Agent&lt;/td&gt;
&lt;td&gt;18/30 (60%)&lt;/td&gt;
&lt;td&gt;n/a*&lt;/td&gt;
&lt;td&gt;$0.037&lt;/td&gt;
&lt;td&gt;113,894&lt;/td&gt;
&lt;td&gt;6.5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;ℹ️ *Not comparable because cost measure was incomplete for some cells. Shared success only counts tasks every harness passed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs67o45spge4sqmtrv2lb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs67o45spge4sqmtrv2lb.png" alt="eval report" width="800" height="520"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Pi actually topped the board
&lt;/h3&gt;

&lt;p&gt;Pi finished first with &lt;strong&gt;21/30 tasks passed&lt;/strong&gt;, while OpenCode landed at &lt;strong&gt;19/30&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8p1e2vviblg8vkreyzku.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8p1e2vviblg8vkreyzku.jpg" alt="agent leaderboard" width="800" height="433"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It was also cheaper across the full run.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pi&lt;/strong&gt; spent $1.64&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenCode&lt;/strong&gt; spent $2.25&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pi cost per success&lt;/strong&gt; was $0.078&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenCode cost per success&lt;/strong&gt; was $0.119&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last number looks rough for OpenCode, but there is a catch.&lt;/p&gt;

&lt;p&gt;On tasks both harnesses actually passed, they were basically tied. Pi came in at &lt;strong&gt;$0.031 per shared success&lt;/strong&gt;, while OpenCode was &lt;strong&gt;$0.032&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So OpenCode was not really more expensive when it worked. It just failed more tasks, and &lt;strong&gt;failed runs still burn tokens&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pi was also painfully slow
&lt;/h3&gt;

&lt;p&gt;Pi had the best pass rate, but it was also the &lt;strong&gt;slowest harness in the entire run&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fekaax7isq4u9zfbsy07i.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fekaax7isq4u9zfbsy07i.jpg" alt="agent time eval comparison" width="800" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pi median time&lt;/strong&gt; was 362.9 seconds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenCode median time&lt;/strong&gt; was 280.6 seconds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude Code median time&lt;/strong&gt; was 181.8 seconds&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pi also averaged &lt;strong&gt;16.3 turns per task&lt;/strong&gt;, compared with OpenCode's &lt;strong&gt;13.1&lt;/strong&gt;, and pushed more raw tokens than anyone else.&lt;/p&gt;

&lt;p&gt;The difference is that its fixed overhead stays tiny.&lt;/p&gt;

&lt;p&gt;The system prompt plus tool definitions stay under 1,000 tokens, and the prefix barely changes between turns. That makes a lot of that repeated context land as cache hits.&lt;/p&gt;

&lt;h3&gt;
  
  
  The task-by-task split is more interesting
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Pi&lt;/th&gt;
&lt;th&gt;OpenCode&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CRM migration archive&lt;/td&gt;
&lt;td&gt;✅ 10/10&lt;/td&gt;
&lt;td&gt;❌ 3/10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slack action items&lt;/td&gt;
&lt;td&gt;✅ 5/5&lt;/td&gt;
&lt;td&gt;❌ 2/5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vendor directory&lt;/td&gt;
&lt;td&gt;✅ 11/13 checks&lt;/td&gt;
&lt;td&gt;❌ 7/12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Handover audit&lt;/td&gt;
&lt;td&gt;✅ 11/11&lt;/td&gt;
&lt;td&gt;❌ 10/11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PagerDuty timeline audit&lt;/td&gt;
&lt;td&gt;✅ 10/10&lt;/td&gt;
&lt;td&gt;❌ 9/10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Renewal screen&lt;/td&gt;
&lt;td&gt;❌ 5/11&lt;/td&gt;
&lt;td&gt;✅ 11/11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sponsorship screen&lt;/td&gt;
&lt;td&gt;❌ 4/11&lt;/td&gt;
&lt;td&gt;✅ 11/12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sheets to Airtable tracker&lt;/td&gt;
&lt;td&gt;❌ 8/10&lt;/td&gt;
&lt;td&gt;✅ 10/10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn5ccw21opj1h7il64v1r.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn5ccw21opj1h7il64v1r.jpg" alt="task-by-task split of agent test" width="800" height="433"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Pi did better on the &lt;strong&gt;audit and census-style tasks&lt;/strong&gt;, where patiently reading everything and checking every detail matters.&lt;/p&gt;

&lt;p&gt;OpenCode did better on the &lt;strong&gt;longer screening and multi-step workflows&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Which is kinda perfect.&lt;/p&gt;

&lt;p&gt;Pi wins by being small. OpenCode wins when the workflow benefits from more stuff around the model.&lt;/p&gt;

&lt;p&gt;After all this non-sense, the eval ended up being spot on for both of them. 🤧&lt;/p&gt;

&lt;p&gt;We did a complete breakdown in this tweet. You might want to &lt;a href="https://x.com/composio/status/2090069397050097864" rel="noopener noreferrer"&gt;check it out&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;iframe class="tweet-embed" id="tweet-2090069397050097864-193" src="https://platform.twitter.com/embed/Tweet.html?id=2090069397050097864"&gt;
&lt;/iframe&gt;

  // Detect dark theme
  var iframe = document.getElementById('tweet-2090069397050097864-193');
  if (document.body.className.includes('dark-theme')) {
    iframe.src = "https://platform.twitter.com/embed/Tweet.html?id=2090069397050097864&amp;amp;theme=dark"
  }



&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Token overhead
&lt;/h2&gt;

&lt;p&gt;This is basically the argument both tools were built around, so let's put some numbers next to it.&lt;/p&gt;

&lt;p&gt;The most cited comparison here is the &lt;a href="https://systima.ai/blog/claude-code-vs-opencode-token-overhead" rel="noopener noreferrer"&gt;Systima study&lt;/a&gt; from July, which also ended up doing &lt;a href="https://news.ycombinator.com/item?id=48883275" rel="noopener noreferrer"&gt;700+ points on Hacker News&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;They put a logging proxy between the harness and the model, then measured how much context gets sent &lt;strong&gt;before the model even sees your prompt&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;Harness&lt;/th&gt;
&lt;th&gt;Fixed overhead per request&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code (measured by Systima)&lt;/td&gt;
&lt;td&gt;~32,800 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenCode (measured by Systima)&lt;/td&gt;
&lt;td&gt;~6,900 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pi&lt;/td&gt;
&lt;td&gt;Under 1,000 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;OpenCode already looks pretty good here. Around &lt;strong&gt;4.7x less overhead than Claude Code&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Then Pi shows up with &lt;strong&gt;under 1,000 tokens&lt;/strong&gt;, including the tool definitions.&lt;/p&gt;

&lt;p&gt;That makes it roughly &lt;strong&gt;7x leaner than OpenCode&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💁 Everyone is somebody's bloatware. 🗿&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Matei Zaharia &lt;a href="https://x.com/matei_zaharia/status/2074943619610649059" rel="noopener noreferrer"&gt;said on X&lt;/a&gt; the reason was pretty simple. Pi had the same success rate with &lt;strong&gt;2x less cost.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyjhr0zfzfpglr03e4nkk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyjhr0zfzfpglr03e4nkk.png" alt="pi success rate at 2x less cost compared to other agents" width="800" height="583"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Our eval points in the same direction too. Pi had the best pass rate and the lowest total spend.&lt;/p&gt;

&lt;p&gt;But there is an important catch here.&lt;/p&gt;

&lt;p&gt;OpenCode has had some pretty ugly token cases too. There is a &lt;a href="https://github.com/anomalyco/opencode/issues/8234" rel="noopener noreferrer"&gt;GitHub issue&lt;/a&gt; where asking something as basic as "what's the tech stack in this project" burned &lt;strong&gt;13,088 tokens&lt;/strong&gt; on a fresh project.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fag06f0ra64r5zoyqw18j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fag06f0ra64r5zoyqw18j.png" alt="excessive token usage of opencode" width="799" height="337"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And Pi is not crazy cheap in every sense either. In our eval, it actually pushed the &lt;strong&gt;highest raw token count of all six harnesses&lt;/strong&gt;, mostly because it took more turns to finish tasks.&lt;/p&gt;

&lt;p&gt;So yeah, tiny fixed overhead helps a lot. But &lt;strong&gt;lean overhead does not always mean lean sessions&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Worth keeping that distinction in mind.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Pricing and the ban
&lt;/h2&gt;

&lt;p&gt;Both tools are free and MIT licensed. Fork them, modify them, ship them.&lt;/p&gt;

&lt;p&gt;The difference starts after install.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pi
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;BYOK for pretty much any provider&lt;/li&gt;
&lt;li&gt;local models through Ollama, vLLM, or llama.cpp&lt;/li&gt;
&lt;li&gt;OAuth login where providers still allow it&lt;/li&gt;
&lt;li&gt;no paid CLI tier at all&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💁 Pi is basically just the harness. You bring the model and pay whoever is serving it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  OpenCode
&lt;/h3&gt;

&lt;p&gt;OpenCode does the same BYOK thing, but it also has its own model options.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;BYOK&lt;/strong&gt; where the provider bills you directly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zen&lt;/strong&gt; with curated models sold on a pay-as-you-go basis&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Go&lt;/strong&gt; with open coding models bundled into a cheap monthly plan&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Go starts at &lt;strong&gt;$5 for the first month, then $10/month&lt;/strong&gt;, with models like GLM, Kimi K3, Qwen, MiniMax, and DeepSeek.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ Double check the pricing. OpenCode changes this stuff pretty often.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And honestly, that Go plan is hard to ignore.&lt;/p&gt;

&lt;p&gt;For $10/month, it is probably one of the cheapest legit ways to run capable open models through a coding agent right now.&lt;/p&gt;

&lt;p&gt;Pi does not really have an equivalent. Its version of "cheap" is running local models, which is useful, but it is not the same thing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Then Anthropic happened
&lt;/h3&gt;

&lt;p&gt;In January 2026, Anthropic started rejecting Claude Pro and Max OAuth tokens when they were used outside Claude Code.&lt;/p&gt;

&lt;p&gt;The error was pretty clear. The credential was &lt;strong&gt;"only authorized for use with Claude Code."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;OpenCode had been using those subscription tokens by mimicking the Claude Code client, so this hit them directly.&lt;/p&gt;

&lt;p&gt;Then came the legal requests, the Claude login code got removed, and we got the now-famous commit message from the intro.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmpc88vg2fnh7c1erd5x1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmpc88vg2fnh7c1erd5x1.png" alt="george hotz calling out anthropic move of removing sub a huge mistake" width="800" height="306"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;George Hotz called the move &lt;strong&gt;"a huge mistake."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;OpenAI reportedly went in the other direction and opened Codex subscription access to third-party harnesses, including OpenCode and Pi.&lt;/p&gt;

&lt;p&gt;Lol.&lt;/p&gt;

&lt;p&gt;The important part for this comparison is pretty simple now.&lt;/p&gt;

&lt;p&gt;If you want Claude in either Pi or OpenCode, you are basically paying API rates. OpenCode also gives you Zen as another route.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Extensibility
&lt;/h2&gt;

&lt;p&gt;Both are highly customizable, but the extension model is very different.&lt;/p&gt;

&lt;h3&gt;
  
  
  OpenCode
&lt;/h3&gt;

&lt;p&gt;OpenCode is mostly config driven through &lt;code&gt;opencode.json&lt;/code&gt;, plugins, agents, skills, and MCP.&lt;/p&gt;

&lt;p&gt;You can add:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;custom agents with their own model, prompt, permissions, and step limits&lt;/li&gt;
&lt;li&gt;JS or TS plugins with lifecycle hooks like &lt;code&gt;tool.execute.before&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;custom tools&lt;/li&gt;
&lt;li&gt;local and remote MCP servers&lt;/li&gt;
&lt;li&gt;skills from &lt;code&gt;.opencode/skills&lt;/code&gt; and &lt;code&gt;.claude/skills&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&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;"permission"&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;"bash"&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;"git *"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"rm *"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deny"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ask"&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;h3&gt;
  
  
  Pi
&lt;/h3&gt;

&lt;p&gt;Pi extensions are TypeScript modules loaded directly into the single agent process.&lt;/p&gt;

&lt;p&gt;They can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;rewrite or block tool calls&lt;/li&gt;
&lt;li&gt;replace built-in tools&lt;/li&gt;
&lt;li&gt;filter history&lt;/li&gt;
&lt;li&gt;replace compaction&lt;/li&gt;
&lt;li&gt;persist custom session state&lt;/li&gt;
&lt;li&gt;add TUI components&lt;/li&gt;
&lt;li&gt;probably many more...&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That gives Pi much &lt;strong&gt;deeper runtime control&lt;/strong&gt; than OpenCode.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;ℹ️ OpenCode plugins extend the harness. Pi extensions can modify the harness itself.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The tradeoff is security. Pi extensions run in process with full system access, so they need more trust than OpenCode's plugin model.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Permissions and safety
&lt;/h2&gt;

&lt;p&gt;This is probably the biggest gap between the two.&lt;/p&gt;

&lt;p&gt;OpenCode has a proper permission system. Every tool can be set to &lt;code&gt;allow&lt;/code&gt;, &lt;code&gt;ask&lt;/code&gt;, or &lt;code&gt;deny&lt;/code&gt;, with glob patterns and per-agent overrides. The defaults are decent too. &lt;code&gt;.env&lt;/code&gt; reads are denied by default, and the Plan agent asks before edits or bash commands.&lt;/p&gt;

&lt;p&gt;What it does &lt;strong&gt;not&lt;/strong&gt; have is real sandboxing. There is no container or OS-level isolation built in.&lt;/p&gt;

&lt;p&gt;Pi goes the other way and basically ships no permission layer at all. From the first prompt, the agent runs with your full user privileges. 🥶&lt;/p&gt;

&lt;p&gt;That is intentional. Zechner's say is that once an agent can write files and execute code, permission prompts only get you so far. Pi instead expects you to isolate the whole process using Docker, a microVM like Gondolin, or a policy sandbox like OpenShell.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💁 There is also an optional &lt;code&gt;damage-control.ts&lt;/code&gt; extension that checks bash commands against YAML rules. Helps a bit.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So the actual difference is pretty simple.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;ℹ️ OpenCode gives you tool-level guardrails. Pi expects you to handle isolation outside the agent.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Which one should you pick?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Pick OpenCode if&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You want something close to a full Claude Code replacement without building half the setup yourself&lt;/li&gt;
&lt;li&gt;You want plan mode, permissions, MCP, undo and redo, and LSP support out of the box&lt;/li&gt;
&lt;li&gt;You want the $10 Go plan for cheap access to capable open models&lt;/li&gt;
&lt;li&gt;You work in typed languages and want LSP diagnostics fed straight back into the agent&lt;/li&gt;
&lt;li&gt;You are setting this up for a team and actually need proper &lt;code&gt;allow&lt;/code&gt;, &lt;code&gt;ask&lt;/code&gt;, and &lt;code&gt;deny&lt;/code&gt; controls&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pick Pi if&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You care about token cost at scale. In our eval, the same model passed more tasks and cost less with Pi&lt;/li&gt;
&lt;li&gt;You want a harness small enough that you can actually read and understand the whole thing&lt;/li&gt;
&lt;li&gt;You like branching while debugging. Pi's session trees are really good for that&lt;/li&gt;
&lt;li&gt;You run local or smaller models where the difference between ~1,000 and ~6,900 tokens of fixed overhead actually matters&lt;/li&gt;
&lt;li&gt;You would rather change the harness in TypeScript than spend time configuring someone else's abstraction&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The benchmark kinda gives away the answer anyway.&lt;/p&gt;

&lt;p&gt;Pi did better on the careful audit-style tasks. OpenCode did better on longer, more structured workflows.&lt;/p&gt;

&lt;p&gt;So there probably isn't one winner for everything.&lt;/p&gt;

&lt;p&gt;Both are free. Try both and keep the one that fits how you work. ✌️&lt;/p&gt;




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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftya4fg1aj5uz7ql0o8hu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftya4fg1aj5uz7ql0o8hu.png" alt="Pi Agent vs. OpenCode test summary result" width="800" height="590"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After all that, I still don’t think there’s one obvious winner here. The scorecard ends &lt;strong&gt;6-6&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;OpenCode wins on adoption, features, and ease of use. Pi won our benchmark with &lt;strong&gt;21/30 tasks vs 19/30&lt;/strong&gt;, lower total spend, and way less fixed overhead.&lt;/p&gt;

&lt;p&gt;The bigger difference is still the same one we started with:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;OpenCode gives you configuration control. Pi gives you runtime control.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you just want to install something and start working, pick &lt;strong&gt;OpenCode&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you want to understand and modify the harness itself, pick &lt;strong&gt;Pi&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And if cost per successful task starts mattering at scale, keep an eye on Pi. 🤷‍♂️&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>opensource</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Pi Agent vs Claude Code After 100 Hours of Real Use 🔥</title>
      <dc:creator>Shrijal Acharya</dc:creator>
      <pubDate>Tue, 11 Aug 2026 13:36:48 +0000</pubDate>
      <link>https://dev.to/composiodev/pi-agent-vs-claude-code-after-100-hours-of-real-use-1dfp</link>
      <guid>https://dev.to/composiodev/pi-agent-vs-claude-code-after-100-hours-of-real-use-1dfp</guid>
      <description>&lt;p&gt;While researching for this post, I found out something funny. Pi, the most interesting coding agent of 2026, was built by a guy who got fed up with Claude Code. 🥶&lt;/p&gt;

&lt;p&gt;&lt;a href="https://x.com/badlogicgames" rel="noopener noreferrer"&gt;Mario Zechner&lt;/a&gt;, the creator of libGDX, was a hardcore Claude Code user. He built &lt;a href="https://mariozechner.at/posts/2025-08-03-cchistory/" rel="noopener noreferrer"&gt;cchistory&lt;/a&gt; just to track its system prompt changes, and even &lt;a href="https://mariozechner.at/posts/2025-08-06-cc-antidebug/" rel="noopener noreferrer"&gt;patched the binary&lt;/a&gt; to add features Anthropic had not shipped.&lt;/p&gt;

&lt;p&gt;Then he quit and wrote his own agent. Four tools, a tiny system prompt, no MCP, no permissions. He named it Pi so nobody could Google it. What a Chad! 🗿&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F18euu1imp5t223k454dv.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F18euu1imp5t223k454dv.gif" alt="swag gif" width="500" height="284"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That small project now has 85k+ GitHub stars, powers OpenClaw, and outscored Claude Code on our own tool use benchmark at a fifth of the cost.&lt;/p&gt;

&lt;p&gt;So I put the two side by side. The default everyone uses, against the minimal harness built as a rejection of it.&lt;/p&gt;

&lt;p&gt;Let's go!&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Claude Code&lt;/th&gt;
&lt;th&gt;Pi&lt;/th&gt;
&lt;th&gt;Winner&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tool price&lt;/td&gt;
&lt;td&gt;Needs a paid plan or API key&lt;/td&gt;
&lt;td&gt;Free, MIT licensed&lt;/td&gt;
&lt;td&gt;Pi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real tool use (our eval)&lt;/td&gt;
&lt;td&gt;16/30 tasks passed, $0.195 per success&lt;/td&gt;
&lt;td&gt;20/30 tasks passed, $0.028 per success&lt;/td&gt;
&lt;td&gt;Pi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Predictable billing&lt;/td&gt;
&lt;td&gt;$20/month flat on Pro&lt;/td&gt;
&lt;td&gt;Pay per token, bills can spike&lt;/td&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model support&lt;/td&gt;
&lt;td&gt;Claude models only but it’s built for it&lt;/td&gt;
&lt;td&gt;20+ providers, 300+ models, mid session switching&lt;/td&gt;
&lt;td&gt;Pi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Out of the box features&lt;/td&gt;
&lt;td&gt;Sub agents, teams, plan mode, MCP, skills, checkpoints&lt;/td&gt;
&lt;td&gt;Four tools and a text box&lt;/td&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Context discipline&lt;/td&gt;
&lt;td&gt;Auto compaction, silent context management&lt;/td&gt;
&lt;td&gt;You see and control every token&lt;/td&gt;
&lt;td&gt;Pi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extensibility depth&lt;/td&gt;
&lt;td&gt;Shell hooks, MCP, skills, plugins&lt;/td&gt;
&lt;td&gt;TypeScript extensions running inside the agent process&lt;/td&gt;
&lt;td&gt;Pi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi agent workflows&lt;/td&gt;
&lt;td&gt;Native sub agents and Agent Teams&lt;/td&gt;
&lt;td&gt;Spawn pi via bash, or install a community extension&lt;/td&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Permissions and guardrails&lt;/td&gt;
&lt;td&gt;Deny by default, five modes, sandboxing&lt;/td&gt;
&lt;td&gt;Full system access from the first prompt&lt;/td&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Surfaces&lt;/td&gt;
&lt;td&gt;Terminal, VS Code, JetBrains, desktop, web, mobile, Slack&lt;/td&gt;
&lt;td&gt;Terminal&lt;/td&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session model&lt;/td&gt;
&lt;td&gt;Linear conversation log&lt;/td&gt;
&lt;td&gt;Branchable session trees with fork and rewind&lt;/td&gt;
&lt;td&gt;Pi&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  In Summary:
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Scorecard: Pi 6, Claude Code 5.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Pi is the cheaper, more flexible, and more transparent harness, especially if you pay per token, switch models, or want full control. Claude Code remains the better daily driver for most people because it ships with stronger guardrails, predictable subscription pricing, and a polished feature set out of the box. The practical answer is to use Claude Code for everyday work and Pi for custom workflows, local models, and cost-sensitive tasks.&lt;/p&gt;

&lt;p&gt;And here is the twist. The scorecard says Pi, but almost everyone who reviews both, including the biggest Pi fans, still runs Claude Code as their daily driver. I will explain why is that in a minute.&lt;/p&gt;

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

&lt;blockquote&gt;
&lt;p&gt;ℹ️ A minimal, open source terminal coding agent that ships four tools and expects you to build the rest yourself.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa91gkp42cqdq38nenpqm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa91gkp42cqdq38nenpqm.png" alt="Pi agent" width="800" height="369"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://pi.dev/" rel="noopener noreferrer"&gt;Pi&lt;/a&gt; is the coding agent Mario Zechner released in late 2025, now maintained under &lt;a href="https://github.com/earendil-works/pi" rel="noopener noreferrer"&gt;Earendil&lt;/a&gt;. Zechner joined Earendil in April 2026 in a blog post he titled "I've sold out," and the core stayed MIT licensed.&lt;/p&gt;

&lt;p&gt;The whole product fits in literally one sentence. The model gets &lt;code&gt;read&lt;/code&gt;, &lt;code&gt;write&lt;/code&gt;, &lt;code&gt;edit&lt;/code&gt;, and &lt;code&gt;bash&lt;/code&gt;, a system prompt of a few hundred tokens, and nothing else.&lt;/p&gt;

&lt;p&gt;His reasoning: frontier models have been RL trained so hard on coding agent behavior that they already know what a coding agent is. You do not need 10,000 tokens of instructions to remind them. Need ripgrep? The model runs &lt;code&gt;rg&lt;/code&gt; through bash. Need GitHub? It runs &lt;code&gt;gh&lt;/code&gt;. Need a browser? Ask Pi to write itself a browser tool.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💁 If you're on the same philosophy, this might be worth reading: &lt;a href="https://composio.dev/content/top-10-cli-tools-for-claude-code" rel="noopener noreferrer"&gt;Top 10 CLI Tools to Level-Up Claude Code&lt;/a&gt;. No MCPs, nothing. Plain raw Bash tools gets most of the work done.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That last part is the actual product. Pi extensions are TypeScript files that run inside the same process as the agent loop, and Pi can read its own source and docs, so the standard workflow when you need a missing feature is to ask Pi to build it.&lt;/p&gt;

&lt;p&gt;Everything Zechner refused to ship, the community shipped as extensions within weeks. Todo lists, plan mode, sub agents, permission gates, even an MCP adapter.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnbszl2dke891ql0slm3e.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnbszl2dke891ql0slm3e.gif" alt="Laugh GIF" width="480" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What is Claude Code
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;ℹ️ Anthropic's batteries included coding agent, and the default the entire agent category gets measured against&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foe7igo0vtukw40guj4zm.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foe7igo0vtukw40guj4zm.jpg" alt="Claude Code" width="800" height="461"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You know this one. &lt;a href="https://code.claude.com/docs/en/overview" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt; started as Boris Cherny's internal AppleScript toy at Anthropic in September 2024, hit research preview in February 2025, and became the fastest scaling developer product anyone has ever measured.&lt;/p&gt;

&lt;p&gt;It ships everything: 10+ built in tools, sub agents, Agent Teams, plan mode, MCP as both client and server, Agent Skills, plugins, hooks, checkpoints with rewind, a permission system with five modes, and it runs in the terminal, VS Code, JetBrains, a desktop app, the browser, and your phone.&lt;/p&gt;

&lt;p&gt;More than 80% of Anthropic's own engineers use it daily, and around "90% of Claude Code's code is now written by Claude Code". 🥴&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Philosophy: Pi vs Claude Code
&lt;/h2&gt;

&lt;p&gt;This comparison only makes sense once you understand that Pi exists because of Claude Code.&lt;/p&gt;

&lt;p&gt;Zechner was an early Claude Code user. His complaint was never that it was bad. His complaint was that it kept changing underneath him. The system prompt and tool definitions shifted on every release, which broke his workflows and changed model behavior.&lt;/p&gt;

&lt;p&gt;So the two tools represent opposite answers to the same question: how much harness does a frontier model actually need?&lt;/p&gt;

&lt;p&gt;Anthropic's answer is interesting because it is drifting toward Pi's. Boris Cherny's team deletes harness code every time a new model ships, and in July 2026 Anthropic removed over 80% of Claude Code's system prompt for the Claude 5 generation models with no measurable loss on their coding evals. The bet behind Claude Code is that Anthropic trains the model and the harness together, so scaffolding today can become model behavior tomorrow.&lt;/p&gt;

&lt;p&gt;Pi's answer is that the scaffolding was never load bearing in the first place.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💁 Anthropic quietly deleting most of its own system prompt is about the strongest validation of Pi's thesis you could ask for. Zechner just got there a year early.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  2. Agent Architecture: Claude Code vs Pi
&lt;/h2&gt;

&lt;p&gt;Both run the same loop underneath. Read the task, call the model, execute tool calls, feed results back, repeat until the model stops asking for tools.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcefop0itn8v17urai239.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcefop0itn8v17urai239.jpg" alt="agent loop" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The difference is everything wrapped around that loop.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Claude Code&lt;/strong&gt; gives the model Read, Write, Edit, Bash, Glob, Grep, WebSearch, WebFetch, NotebookEdit, TodoWrite, there's a lot.... and the Task tool that spawns typed sub agents with their own isolated context windows. Plan mode forces a read only explore and plan phase before any file gets touched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pi&lt;/strong&gt; gives the model four tools, plus optional read only &lt;code&gt;grep&lt;/code&gt;, &lt;code&gt;find&lt;/code&gt;, and &lt;code&gt;ls&lt;/code&gt; behind a &lt;code&gt;-tools&lt;/code&gt; flag. There is no plan mode, but &lt;code&gt;pi --tools read,grep,find,ls&lt;/code&gt; gets you a read only session in one line. There are no sub agents, and they say that if you want one, tell Pi to run itself through bash, ideally in tmux so you can actually watch it work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The system prompt gap is the main highlight:&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;System prompt&lt;/th&gt;
&lt;th&gt;Built in tools&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code (pre Claude 5 era)&lt;/td&gt;
&lt;td&gt;~10,000 to 14,000 tokens by community measurement&lt;/td&gt;
&lt;td&gt;10+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code (current)&lt;/td&gt;
&lt;td&gt;Cut by over 80% for Claude 5 gen models&lt;/td&gt;
&lt;td&gt;10+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pi&lt;/td&gt;
&lt;td&gt;Under 1,000 tokens including tool definitions&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One less obvious architectural difference: Pi stores sessions as trees, not logs. Every message carries a parent id, so under the hood this is basically a linked list that learned to branch.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A real use case for linked lists, in the wild. All that leetcode grind had something useful. 🤡&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So &lt;code&gt;/tree&lt;/code&gt; lets you jump back to any earlier point in the conversation and branch from there, and &lt;code&gt;/fork&lt;/code&gt; spins a new session off any past message. Claude Code got checkpoints and rewind in 2.0, which covers the common case, but Pi's tree model is the better design.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. The benchmark: same model, different harness
&lt;/h2&gt;

&lt;p&gt;We ran 30 hard agentic tool use tasks against real apps, eight harnesses, every single one wired to the same model, DeepSeek V4 Flash, through our hosted MCP router, with a 900 second ceiling per task.&lt;/p&gt;

&lt;p&gt;Same model, same tasks, same tools. The only thing that changes is the harness, so whatever gap shows up is the wrapper, not the model.&lt;/p&gt;

&lt;p&gt;Here is how our two contenders did:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Harness (DeepSeek V4 Flash, hard 30 set)&lt;/th&gt;
&lt;th&gt;Tasks passed&lt;/th&gt;
&lt;th&gt;Median time&lt;/th&gt;
&lt;th&gt;Avg tokens per task&lt;/th&gt;
&lt;th&gt;Total cost&lt;/th&gt;
&lt;th&gt;Cost per success&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;td&gt;16/30 (53.3%)&lt;/td&gt;
&lt;td&gt;122.7s&lt;/td&gt;
&lt;td&gt;741,659&lt;/td&gt;
&lt;td&gt;$3.12&lt;/td&gt;
&lt;td&gt;$0.195&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pi&lt;/td&gt;
&lt;td&gt;20/30 (66.7%)&lt;/td&gt;
&lt;td&gt;132.2s&lt;/td&gt;
&lt;td&gt;558,885&lt;/td&gt;
&lt;td&gt;$0.56&lt;/td&gt;
&lt;td&gt;$0.028&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1g15qyiwhm1p1k1gs8pw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1g15qyiwhm1p1k1gs8pw.png" alt="eight agent harnesses eval" width="799" height="287"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Pi passed more tasks than any of the eight harnesses we tested, and did it at the lowest cost of the whole bunch. Cost per successful task is the brutal column: &lt;strong&gt;3 cents against 19&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Claude Code posted the fastest median time. It just burned 741,659 tokens per task getting there, against Pi's 558,885. The overhead is the story, again.&lt;/p&gt;

&lt;p&gt;And yes, that is Claude Code running DeepSeek. That is the whole point of the setup. Only the harness changes.&lt;/p&gt;

&lt;p&gt;For the curious, Codex ran the same and landed at 16/30 for $1.29 total. Right between the two.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8zelou5gp9orqip73xjn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8zelou5gp9orqip73xjn.png" alt="agent harness report" width="800" height="479"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;NOTE:&lt;/strong&gt; Pi ran on high reasoning instead of max, and 24 of its 30 trials went through the official DeepSeek API instead of OpenRouter. Make sure to read the exact numbers with that in mind.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  4. Pricing: Claude Code vs Pi
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Claude Code
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Pro, $20/month or $17/month annually, includes Claude Code&lt;/li&gt;
&lt;li&gt;Max 5x at $100/month, Max 20x at $200/month&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ Guys, make sure to double check the pricing. It changes frequently.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Pi
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;The tool costs nothing. MIT license, fork it, ship it&lt;/li&gt;
&lt;li&gt;You bring your own API keys for any provider, or run local models for free through Ollama, vLLM, or llama.cpp&lt;/li&gt;
&lt;li&gt;OAuth login for subscriptions where providers allow it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On paper Pi wins on price and Claude Code wins on predictability. A $20 flat rate is genuinely hard to beat for a solo dev using Claude models all day, and per token bills on heavy Pi usage can hit hundreds of dollars a month. I usually prefer the subscription system.&lt;/p&gt;

&lt;p&gt;Anthropic locked its subscription OAuth tokens to Claude Code and &lt;a href="http://claude.ai/" rel="noopener noreferrer"&gt;Claude.ai&lt;/a&gt; only. Third party harnesses got cut off, starting with a silent server side block in January, formalized in the docs by February, fully enforced by April. OpenClaw, OpenCode, Cline, and yes, Pi, all lost the ability to run on your Claude Pro or Max subscription.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo2a55k881h4c2noc8c2q.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo2a55k881h4c2noc8c2q.webp" alt="anthropic locking subscription for third party providers" width="800" height="757"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So the practical situation today: running Claude models through Pi means API rates. Running them through Claude Code means your cheap subscription.&lt;/p&gt;

&lt;p&gt;DHH called the move "very customer hostile," Lol.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq8w1n0o9c5089f3wal0m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq8w1n0o9c5089f3wal0m.png" alt="DHH calling anthropic move costomer hostile" width="800" height="687"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;OpenAI's Codex team publicly went the opposite direction and encouraged third party harness usage.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💁 If your workflow is Claude models all day every day, the subscription math alone mostly settles this comparison. Pi's cost advantage is real on API keys and irrelevant on a Max plan you already pay for.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  5. Models: one provider vs all of them
&lt;/h2&gt;

&lt;p&gt;Claude Code runs Claude. Opus, Sonnet, and Haiku tiers, switchable with &lt;code&gt;/model&lt;/code&gt;, with thinking effort levels, tuned end to end because Anthropic controls the whole stack.&lt;/p&gt;

&lt;p&gt;That vertical integration is why the model feels so at home in the harness. It was trained in it.&lt;/p&gt;

&lt;p&gt;Pi runs everything. The &lt;code&gt;pi-ai&lt;/code&gt; layer normalizes four API shapes across 20+ providers, from Anthropic and OpenAI to Groq, Cerebras, Mistral, OpenRouter, and local models. Over 300 models total. You can switch models mid session with &lt;code&gt;Ctrl+P&lt;/code&gt;, and Pi converts thinking traces between providers.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9nk81lwqadsamqgaf7n2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9nk81lwqadsamqgaf7n2.png" alt="multiple model providers in pi agent" width="800" height="458"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Claude Code's giant prompt overhead makes it a poor fit for small local models, which is exactly why Simon Willison flagged Pi as the lightweight option for that use case.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Extensibility
&lt;/h2&gt;

&lt;p&gt;Both tools are deeply customizable.&lt;/p&gt;

&lt;p&gt;Claude Code's model is external and config driven. Hooks are shell scripts that fire on 14 lifecycle events and talk JSON over stdin. MCP servers are separate processes speaking a protocol. Skills are markdown instruction packs with lazy loading. Plugins bundle all of it into installable packages with official and community marketplaces behind a single &lt;code&gt;/plugin&lt;/code&gt; command.&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;"permissions"&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;"allow"&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;"Bash(npm run test *)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash(git status *)"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"deny"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Read(./.env)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash(curl *)"&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;Pi's model is internal. Extensions are TypeScript modules loaded into the same runtime as the agent loop, hooking 25 event types. They can block or rewrite tool calls before they run, override the built in tools entirely, inject context, replace the compaction logic, persist custom state into the session file, and draw their own TUI widgets. Claude Code's hooks can approve or reject what the agent does. Pi's extensions can change what the agent is.&lt;/p&gt;

&lt;p&gt;The tradeoff is trust. Claude Code's marketplace plugins go through some checks and run outside the core process. Pi packages run with full system access, and its own docs tell you to review them before installing.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The sharpest criticism of Pi's ecosystem, from &lt;a href="https://www.thevinter.com/blog/bad-vibes-from-pi" rel="noopener noreferrer"&gt;thevinter's "Bad Vibes From Pi"&lt;/a&gt;, lands exactly here: a lot of those community extensions are themselves vibecoded, so that's something to watch-out for.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8nbc83mudu2tor7799jt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8nbc83mudu2tor7799jt.png" alt="Pi agent extensions probable harm talk" width="800" height="265"&gt;&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  7. Permissions and safety
&lt;/h2&gt;

&lt;p&gt;This is the widest gap between the two tools, and neither side considers it a flaw.&lt;/p&gt;

&lt;p&gt;Claude Code is deny by default. Every risky action asks first, and you can loosen from there through five permission modes, allow and deny rules, and OS level sandboxing for bash.&lt;/p&gt;

&lt;p&gt;When Claude Code has security incidents, and it has had real ones with CVEs attached, they get found, patched, and written up because millions of people are using it.&lt;/p&gt;

&lt;p&gt;Pi has no permission system. First prompt, full user privileges, go.&lt;/p&gt;

&lt;p&gt;Zechner's argument is that agent security is mostly "theater" 🫩, because once a tool can write code and run code the game is already over. Pi's answer to safety is isolation instead of prompts: run it in Docker, in a micro VM, or behind a sandbox, and there is an opt in extension that checks bash commands against YAML rules if you want a middle ground.&lt;/p&gt;

&lt;p&gt;One of them requires you to have read the docs, and the other protects the "intern" who did not.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;There isn't much to discuss here. You probably already know how things work by now...&lt;/p&gt;
&lt;/blockquote&gt;


&lt;h2&gt;
  
  
  What the Internet thinks
&lt;/h2&gt;

&lt;p&gt;Maybe worth seeing what the internet thinks of Pi and Claude Code by now?&lt;/p&gt;

&lt;p&gt;It's more of what we are discussing so far. The famous endorsements is around Pi, the daily usage clusters around Claude Code, and both facts are true.&lt;/p&gt;

&lt;p&gt;Let me show you the Pi love first, because it is a lot.&lt;/p&gt;

&lt;p&gt;Peter Steinberger built OpenClaw on top of Pi and quote tweeted Zechner with "Mario is the real one we don't deserve." Armin Ronacher wrote that Pi is &lt;a href="https://lucumr.pocoo.org/2026/1/31/pi/" rel="noopener noreferrer"&gt;the coding agent he uses "almost exclusively"&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fai19ky33q5v99uvizdsp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fai19ky33q5v99uvizdsp.png" alt="openclaw creator praising pi agent creator" width="800" height="321"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Theo from &lt;a href="http://t3.gg/" rel="noopener noreferrer"&gt;t3.gg&lt;/a&gt; did a whole episode about getting Pi pilled. His take was simple: "The more minimal, the better." Wes Bos and Scott Tolinski spent a Syntax episode arguing Claude Code is overkill for most work.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/3DNkDIVKtK8"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;My favorite line comes from &lt;a href="https://agenticengineer.com/the-only-claude-code-competitor" rel="noopener noreferrer"&gt;IndyDevDan&lt;/a&gt;, who calls Pi the only real Claude Code competitor: "Claude Code is the starter pack. Pi is the endgame."&lt;/p&gt;

&lt;p&gt;About 80% of his work still goes through Claude Code. Only 20% goes through Pi. The endgame guy is still playing the starter pack. The internet is wild!&lt;/p&gt;

&lt;p&gt;Claude Code does not need loud fans anyway. It has $2.5B in revenue (might increase, researched through Claude itself), 141k GitHub stars, most of the enterprise market, and Microsoft reportedly using it inside their own teams. The numbers are just enough, lol. 🤧&lt;/p&gt;


&lt;h2&gt;
  
  
  Which one should you pick?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Pick Claude Code if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You want something that works at full power&lt;/li&gt;
&lt;li&gt;You live on Claude models, because the $20 to $200 subscriptions are the cheapest way to run them and Pi is locked out of that pricing&lt;/li&gt;
&lt;li&gt;You are deploying to a team and need permissions, SSO, spend caps, and audit trails&lt;/li&gt;
&lt;li&gt;You want sub agents, plan mode, and multi surface access without building anything&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pick Pi if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You pay per token at scale, since less context per task is real money, a fifth of the cost in our eval&lt;/li&gt;
&lt;li&gt;You want to mix providers, run open models, or go fully local&lt;/li&gt;
&lt;li&gt;You want to read, own, and modify every part of your harness, MIT license and all&lt;/li&gt;
&lt;li&gt;You are the kind of engineer who would rather build the exact workflow you want than configure someone else's&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Do what most reviewers actually do:&lt;/strong&gt; run both. Claude Code as the primary builder on your subscription, Pi as the cheap second opinion, the local model runner, and the place where your custom workflows live. The two tools are converging anyway.&lt;/p&gt;

&lt;p&gt;Claude Code keeps deleting scaffolding, and the Pi community keeps rebuilding Claude Code's features as extensions. 🤡&lt;/p&gt;


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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd12fa7k79ota3dwk50uj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd12fa7k79ota3dwk50uj.png" alt="pi agent vs claude code final score" width="799" height="512"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The scorecard reads Pi 6, Claude Code 5. And honestly, Pi deserves those wins. It is cheaper per task, open source, works with any model. What more do you want?&lt;/p&gt;

&lt;p&gt;But here is the thing. When I sit down with a bug and just want it fixed, I open Claude Code. It works right away, the guardrails just works, the $20 plan keeps the bill consistent, and the model just feels at home in it.&lt;/p&gt;

&lt;p&gt;No thinking about the tool itself.&lt;/p&gt;

&lt;p&gt;That is the whole story really. Even the biggest Pi fans ship most of their work through Claude Code. It's a secret. 🤫&lt;/p&gt;

&lt;p&gt;Zechner was right that these agents need way less scaffolding than we thought. Anthropic deleting 80% of its own system prompt basically admitted it. But being right and being the tool everyone opens every day are two different things, and Claude Code still owns the second one for me.&lt;/p&gt;

&lt;p&gt;So use Claude Code for the day job. Keep Pi around for when you want to tinker, go local, or save some money. ✌️&lt;/p&gt;


&lt;div class="ltag__user ltag__user__id__1127015"&gt;
    &lt;a href="/shricodev" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1127015%2F1c5e48a2-f602-4e7d-8312-3c0322d155c6.jpg" alt="shricodev image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/shricodev"&gt;Shrijal Acharya&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/shricodev"&gt;SDE • GOLD @Microsoft Student Ambassador • Prev Lead Collab and Dev-Team Lead @oppiaorg • Mail for collaboration&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;



</description>
      <category>ai</category>
      <category>productivity</category>
      <category>opensource</category>
      <category>architecture</category>
    </item>
    <item>
      <title>How to build and deploy an MCP server to production (2026-07-28 spec)</title>
      <dc:creator>Shrijal Acharya</dc:creator>
      <pubDate>Wed, 05 Aug 2026 12:13:02 +0000</pubDate>
      <link>https://dev.to/composiodev/how-to-build-and-deploy-an-mcp-server-to-production-2026-07-28-spec-50p3</link>
      <guid>https://dev.to/composiodev/how-to-build-and-deploy-an-mcp-server-to-production-2026-07-28-spec-50p3</guid>
      <description>&lt;p&gt;MCP just had its biggest release since launch.&lt;/p&gt;

&lt;p&gt;On July 28, the maintainers shipped the &lt;strong&gt;2026-07-28 spec&lt;/strong&gt;, and it changes how MCP servers work at a pretty fundamental level. The handshake is gone. Sessions are gone. Three long-standing features are deprecated.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmsfvl2yvl7lis32ovbob.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmsfvl2yvl7lis32ovbob.png" alt="tweet on mcp" width="800" height="680"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The maintainers themselves called it the most substantial change since authorization was added. Their words, not mine.&lt;/p&gt;

&lt;p&gt;Sounds scary. But it actually makes MCP servers much easier to deploy. And what am I here for? I'm here to help you build and deploy one.&lt;/p&gt;

&lt;p&gt;Your MCP server is now just a regular stateless HTTP service. Round-robin load balancing, autoscaling, and caching all work. No sticky sessions or shared session state.&lt;/p&gt;

&lt;p&gt;In this guide, we'll build a small MCP server on the new spec, connect a client to it, see every headline feature actually running, and then deploy it to Cloudflare Workers. For free.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;ℹ️ All the code here uses the new &lt;strong&gt;TypeScript SDK v2&lt;/strong&gt;, released alongside the spec. If you're on the old &lt;code&gt;@modelcontextprotocol/sdk&lt;/code&gt; package, that's v1 now.&lt;/p&gt;
&lt;/blockquote&gt;




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

&lt;ul&gt;
&lt;li&gt;What actually changed in the 2026-07-28 spec in short&lt;/li&gt;
&lt;li&gt;Building an MCP server with the new SDK v2&lt;/li&gt;
&lt;li&gt;Stateless core in action&lt;/li&gt;
&lt;li&gt;MRTR: how a tool requests user confirmation without holding a stream open&lt;/li&gt;
&lt;li&gt;A graceful fallback for clients that don't speak MRTR yet (there are many)&lt;/li&gt;
&lt;li&gt;Cacheable tool lists with &lt;code&gt;ttlMs&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Testing it with a client and raw curl&lt;/li&gt;
&lt;li&gt;Deploying it to Cloudflare Workers on the free plan&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Changes in the new MCP Spec (2026-07-28)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fujro30vnaf3p3byd4d7y.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fujro30vnaf3p3byd4d7y.webp" alt="mcp new workflow" width="800" height="417"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Quick rundown of what's new. If you want the full changelog, it's on the &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/changelog" rel="noopener noreferrer"&gt;official spec site&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The handshake is gone
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;initialize&lt;/code&gt; / &lt;code&gt;initialized&lt;/code&gt; exchange and the Mcp-Session-Id header are officially retired.&lt;/p&gt;

&lt;p&gt;Every request is now &lt;strong&gt;self-describing&lt;/strong&gt;. It carries its own protocol version, client identity, and capabilities in &lt;code&gt;_meta&lt;/code&gt;. Any request can land on any server instance behind a plain load balancer. Such a relief!!&lt;/p&gt;

&lt;p&gt;There's an optional &lt;code&gt;server/discover&lt;/code&gt; RPC if a client wants capabilities up front. But it's optional. One bare POST is a complete conversation now.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multi Round-Trip Requests (MRTR)
&lt;/h3&gt;

&lt;p&gt;This one is my favorite.&lt;/p&gt;

&lt;p&gt;Before, if a tool needed something from the user mid-call, such as confirmation or a missing parameter, the server had to push an &lt;code&gt;elicitation/create&lt;/code&gt; request back over a held-open stream. That meant you needed a held-open stream, which was bad for stateless deployments.&lt;/p&gt;

&lt;p&gt;MRTR flips it. The server returns &lt;code&gt;resultType: "input_required"&lt;/code&gt; with the questions it needs answered, and closes the connection. The client collects the answers and retries the original call with them attached, plus an opaque &lt;code&gt;requestState&lt;/code&gt; token so the server knows where it left off.&lt;/p&gt;

&lt;p&gt;No open streams. No sessions. Interactive tools on fully stateless infra.&lt;/p&gt;

&lt;h3&gt;
  
  
  Header-based routing
&lt;/h3&gt;

&lt;p&gt;Requests now carry &lt;code&gt;Mcp-Method&lt;/code&gt; and &lt;code&gt;Mcp-Name&lt;/code&gt; HTTP headers. Your gateway, rate limiter, or WAF can route and meter on headers without parsing JSON bodies.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cacheable list results
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;tools/list&lt;/code&gt;, &lt;code&gt;prompts/list&lt;/code&gt;, &lt;code&gt;resources/list&lt;/code&gt;, and &lt;code&gt;resources/read&lt;/code&gt; responses now carry &lt;code&gt;ttlMs&lt;/code&gt; and &lt;code&gt;cacheScope&lt;/code&gt; fields, modeled on HTTP's Cache-Control. Clients cache your tool catalog instead of re-fetching it every time they connect.&lt;/p&gt;

&lt;h3&gt;
  
  
  Extensions framework + deprecations
&lt;/h3&gt;

&lt;p&gt;Tasks moved out of the experimental core into an official extension (&lt;code&gt;io.modelcontextprotocol/tasks&lt;/code&gt;). MCP Apps and Enterprise Managed Authorization live there too. You can build your own extensions as well.&lt;/p&gt;

&lt;p&gt;And the deprecations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Roots, Sampling, and Logging&lt;/strong&gt; are deprecated. They keep working for at least 12 months, but new implementations shouldn't use them.&lt;/li&gt;
&lt;li&gt;The legacy &lt;strong&gt;HTTP+SSE transport&lt;/strong&gt; is deprecated with a year-long offramp.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic Client Registration&lt;/strong&gt; is deprecated in favor of Client ID Metadata Documents (CIMD).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's also a formal deprecation policy now: a 12-month minimum window for anything marked deprecated. So you get to plan upgrades, which is noicee!&lt;/p&gt;

&lt;h3&gt;
  
  
  The SDK Split
&lt;/h3&gt;

&lt;p&gt;One more thing before we build: the TypeScript SDK is no longer one package.&lt;/p&gt;

&lt;p&gt;v2 splits it into &lt;code&gt;@modelcontextprotocol/server&lt;/code&gt;, &lt;code&gt;@modelcontextprotocol/client&lt;/code&gt;, and thin framework adapters (&lt;code&gt;@modelcontextprotocol/hono&lt;/code&gt;, &lt;code&gt;express&lt;/code&gt;, &lt;code&gt;fastify&lt;/code&gt;, &lt;code&gt;node&lt;/code&gt;).&lt;/p&gt;




&lt;h2&gt;
  
  
  Building an MCP Server
&lt;/h2&gt;

&lt;p&gt;Finally, we're onto the build. We will build a quick tiny deploy bot over MCP.&lt;/p&gt;

&lt;p&gt;It has three tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;deploy&lt;/code&gt; asks the user for confirmation before deploying (MRTR in action).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;list_deployments&lt;/code&gt; reads back the deployment history&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;server_stats&lt;/code&gt; proves a fresh server instance handled every request&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the trick that pays off at deploy time: all the MCP logic lives in &lt;strong&gt;one platform-neutral file&lt;/strong&gt; (&lt;code&gt;bot.ts&lt;/code&gt;), and each platform gets a tiny entry file. Node gets &lt;code&gt;server.ts&lt;/code&gt;. Cloudflare gets &lt;code&gt;worker.ts&lt;/code&gt;. Both are about ten lines. An MCP server on the new spec is just a fetch handler; the platform is a serving shim.&lt;/p&gt;

&lt;p&gt;You'll understand everything along the way.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Install the SDK v2
&lt;/h3&gt;

&lt;p&gt;Run the following command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;updated-mcp-spec-bot &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;updated-mcp-spec-bot
npm init &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm pkg &lt;span class="nb"&gt;set type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;module
npm &lt;span class="nb"&gt;install&lt;/span&gt; @modelcontextprotocol/server @modelcontextprotocol/client &lt;span class="se"&gt;\&lt;/span&gt;
  @modelcontextprotocol/hono @hono/node-server hono zod tsx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;blockquote&gt;
&lt;p&gt;ℹ️ On TypeScript 6+, add &lt;code&gt;"types": ["node"]&lt;/code&gt; to your tsconfig &lt;code&gt;compilerOptions&lt;/code&gt; after installing &lt;code&gt;@types/node&lt;/code&gt;. TS 6 no longer auto-includes &lt;code&gt;@types/*&lt;/code&gt;, and you'll get &lt;code&gt;Cannot find name 'process'&lt;/code&gt; errors without it. Ask me how I know. 😴&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  Step 2: The server logic
&lt;/h3&gt;

&lt;p&gt;Create &lt;code&gt;bot.ts&lt;/code&gt;. This is the whole MCP server, with zero platform code in it:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 👇 bot.ts&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;CallToolResult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;InputRequiredResult&lt;/span&gt;&lt;span class="p"&gt;,&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/server&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;acceptedContent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;CLIENT_CAPABILITIES_META_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;createRequestStateCodec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;inputRequired&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;McpServer&lt;/span&gt;&lt;span class="p"&gt;,&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/server&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="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;z&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;zod/v4&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;deployments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;requestsServed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;DeployState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;step&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;confirm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// set STATE_KEY in production so all instances share the secret&lt;/span&gt;
&lt;span class="c1"&gt;// lazy init: Workers forbids generating random values at module scope&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;codec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;ReturnType&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;createRequestStateCodec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;DeployState&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;stateCodec&lt;/span&gt;&lt;span class="p"&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;codec&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;globalThis&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;STATE_KEY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;codec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;createRequestStateCodec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;DeployState&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;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;
        &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRandomValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
      &lt;span class="na"&gt;ttlSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;600&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="nx"&gt;codec&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;CONFIRM_SCHEMA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;confirm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;boolean&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;confirm&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;// runs per request, keep it cheap&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildServer&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;McpServer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;requestsServed&lt;/span&gt;&lt;span class="o"&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;server&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;McpServer&lt;/span&gt;&lt;span class="p"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;updated-mcp-spec-bot&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="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;cacheHints&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;tools/list&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;ttlMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cacheScope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;public&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;requestState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;verify&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;stateCodec&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;verify&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="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;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;list_deployments&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;List deployments&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;List all deployments recorded by this server.&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="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CallToolResult&lt;/span&gt;&lt;span class="o"&gt;&amp;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;content&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;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;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;deployments&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="nx"&gt;deployments&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;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;d&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="s2"&gt; @ &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;at&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;No deployments yet.&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="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;server_stats&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;Server stats&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;How many requests this process served, each on a fresh server instance.&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="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CallToolResult&lt;/span&gt;&lt;span class="o"&gt;&amp;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;content&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;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;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`pid=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;globalThis&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;pid&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;edge&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; requestsServed=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;requestsServed&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;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deploy&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;Deploy&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;Deploy to an environment. Requires confirmation: interactive clients get a prompt, others must pass confirm: true.&lt;/span&gt;&lt;span class="dl"&gt;"&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;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;env&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;staging&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;prod&lt;/span&gt;&lt;span class="dl"&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;Target environment&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="na"&gt;confirm&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;boolean&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="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;Set true to confirm, only after asking the user&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="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&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;confirm&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CallToolResult&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;InputRequiredResult&lt;/span&gt;&lt;span class="o"&gt;&amp;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;caps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mcpReq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;envelope&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)?.[&lt;/span&gt;
          &lt;span class="nx"&gt;CLIENT_CAPABILITIES_META_KEY&lt;/span&gt;
        &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getClientCapabilities&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;canElicit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;caps&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;elicitation&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;elicitation&lt;/span&gt;&lt;span class="p"&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;canElicit&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;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mcpReq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requestState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;DeployState&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;confirmed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;acceptedContent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;confirm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&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;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mcpReq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inputResponses&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;confirm&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="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;state&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;confirmed&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;confirm&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="nf"&gt;inputRequired&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="na"&gt;inputRequests&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
              &lt;span class="na"&gt;confirm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;inputRequired&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;elicit&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;`Deploy to &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="s2"&gt;? This will go live.`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="na"&gt;requestedSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CONFIRM_SCHEMA&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;requestState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;stateCodec&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;mint&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;step&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;confirm&lt;/span&gt;&lt;span class="dl"&gt;"&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="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;record&lt;/span&gt; &lt;span class="o"&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="nx"&gt;state&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;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
        &lt;span class="nx"&gt;deployments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;record&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;content&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;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;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Deployed to &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;record&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="s2"&gt; at &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;at&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;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="c1"&gt;// fallback for clients without elicitation support&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;confirm&lt;/span&gt; &lt;span class="o"&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;content&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;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;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Deploy to &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="s2"&gt; needs confirmation. Ask the user, then call deploy again with confirm: true.`&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&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;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
      &lt;span class="nx"&gt;deployments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;record&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;content&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;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;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Deployed to &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;record&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="s2"&gt; at &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;at&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;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="nx"&gt;server&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;A few things worth explaining here:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;buildServer()&lt;/code&gt; runs on every single request. Not once at startup. Every request gets a brand-new &lt;code&gt;McpServer&lt;/code&gt; instance.&lt;/p&gt;

&lt;p&gt;If that surprises you, I get it. It surprised me too. But this is literally the canonical pattern from the SDK's own examples, and it's the whole point of the release.&lt;/p&gt;

&lt;p&gt;Construction is just object creation and a handler map, microseconds of work. There's no protocol state to preserve anymore, so there's nothing to keep alive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-request server construction, per-process resources&lt;/strong&gt;. App state (our deployments array, the state codec, your DB pool in real life) lives at module level. The server instance is disposable.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;deploy&lt;/code&gt; tool never blocks. When it needs confirmation, it returns &lt;code&gt;inputRequired(...)&lt;/code&gt; and the request is over. Done. Connection closed. The &lt;code&gt;requestState&lt;/code&gt; token is the only thing that survives between rounds, and it round-trips through the client.&lt;/p&gt;

&lt;p&gt;This means the client could tamper with it. That's why we seal it with &lt;code&gt;createRequestStateCodec&lt;/code&gt;, so tampered or expired state gets rejected with a wire-level error before our handler even runs.&lt;/p&gt;

&lt;p&gt;Notice the codec is &lt;strong&gt;lazily created&lt;/strong&gt; on first use instead of at module level. That looks like a pointless indirection on Node. It's not. Cloudflare Workers forbids generating random values in global scope, and this exact line is what lets the same file run on both platforms. Same story with the &lt;code&gt;globalThis.process?.&lt;/code&gt; guards: Workers has no &lt;code&gt;process&lt;/code&gt; global by default.&lt;/p&gt;

&lt;p&gt;So the tool reads the client's declared capabilities from the per-request envelope (that's the &lt;code&gt;CLIENT_CAPABILITIES_META_KEY&lt;/code&gt; lookup, with a legacy-connection fallback) and if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Client supports elicitation then the full MRTR confirmation flow&lt;/li&gt;
&lt;li&gt;Client doesn't then the tool accepts an optional &lt;code&gt;confirm: true&lt;/code&gt; argument, and without it, it returns a plain instruction: "Ask the user, then call deploy again with confirm: true"&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Step 3: The Node entry
&lt;/h3&gt;

&lt;p&gt;Create &lt;code&gt;server.ts&lt;/code&gt;. This is everything Node-specific:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 👇 server.ts&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;serve&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;@hono/node-server&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;createMcpHonoApp&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/hono&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;createMcpHandler&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/server&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;buildServer&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;./bot.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;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createMcpHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buildServer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// in production set ALLOWED_HOSTS to your public domain&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allowedHosts&lt;/span&gt; &lt;span class="o"&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;ALLOWED_HOSTS&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;h&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;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&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;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createMcpHonoApp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;allowedHosts&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;allowedHosts&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/healthz&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="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="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/mcp&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="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;handler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;raw&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;port&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&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;PORT&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;3000&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;hostname&lt;/span&gt; &lt;span class="o"&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;HOST&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;127.0.0.1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hostname&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`updated-mcp-spec-bot listening on &amp;lt;http://$&amp;gt;{hostname}:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/mcp`&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;That's it. &lt;code&gt;createMcpHandler&lt;/code&gt; gives you a standard fetch-style handler, and Hono is just routing. &lt;code&gt;createMcpHonoApp()&lt;/code&gt; validates Host/Origin headers (DNS rebinding protection) and only allows localhost out of the box, so the &lt;code&gt;ALLOWED_HOSTS&lt;/code&gt; env var is there for when this runs behind a real domain.&lt;/p&gt;

&lt;p&gt;Everything is env-driven (&lt;code&gt;PORT&lt;/code&gt;, &lt;code&gt;HOST&lt;/code&gt;, &lt;code&gt;ALLOWED_HOSTS&lt;/code&gt;, &lt;code&gt;STATE_KEY&lt;/code&gt;) because that's what a VM or a PaaS like Railway wants. We won't use this file for the Cloudflare deploy, but it's your path if you'd rather run this on Node anywhere.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 4: The client
&lt;/h3&gt;

&lt;p&gt;Create &lt;code&gt;client.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 👇 client.ts&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;Client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;StreamableHTTPClientTransport&lt;/span&gt;&lt;span class="p"&gt;,&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/client&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;url&lt;/span&gt; &lt;span class="o"&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;MCP_URL&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;http://127.0.0.1:3000/mcp&amp;gt;&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;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="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;mcp-demooo-client&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="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;elicitation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;form&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;versionNegotiation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;auto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// use 2026-07-28 when the server does&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// The elicitation handler: in a real app this renders a confirm dialog.&lt;/span&gt;
&lt;span class="c1"&gt;// Here we auto-accept and log what the server asked.&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;setRequestHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;elicitation/create&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="k"&gt;as&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="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`\n[elicitation] server asks: "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" -&amp;gt; answering yes`&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;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;accept&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;confirm&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="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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StreamableHTTPClientTransport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`connected, negotiated protocol: &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="nf"&gt;getNegotiatedProtocolVersion&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="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="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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ttlMs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cacheScope&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tools&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ttlMs&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;cacheScope&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`tools/list: &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="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="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`cache hints: ttlMs=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ttlMs&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; cacheScope=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cacheScope&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="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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;second listTools served from cache&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;before&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;callTool&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;list_deployments&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`list_deployments: &lt;/span&gt;&lt;span class="p"&gt;${(&lt;/span&gt;&lt;span class="nx"&gt;before&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&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="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nx"&gt;text&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="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="nx"&gt;client&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="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;deploy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;arguments&lt;/span&gt;&lt;span class="p"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;prod&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`deploy: &lt;/span&gt;&lt;span class="p"&gt;${(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&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="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nx"&gt;text&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stats&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;callTool&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;server_stats&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`server_stats: &lt;/span&gt;&lt;span class="p"&gt;${(&lt;/span&gt;&lt;span class="nx"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&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="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nx"&gt;text&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;after&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;callTool&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;list_deployments&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`list_deployments: &lt;/span&gt;&lt;span class="p"&gt;${(&lt;/span&gt;&lt;span class="nx"&gt;after&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&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="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nx"&gt;text&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="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;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;blockquote&gt;
&lt;p&gt;⚠️ Don't miss &lt;code&gt;versionNegotiation: { mode: 'auto' }&lt;/code&gt;. Without it, the client negotiates the legacy 2025-11-25 protocol and the MRTR flow fails. This took me half an hour to debug.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Notice the elicitation handler is a completely normal &lt;code&gt;elicitation/create&lt;/code&gt; handler, the same one you'd write for the old flow. The SDK's auto-fulfillment engine routes the embedded MRTR request through it and retries the tool call for you. Your code doesn't even see the round trip.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 5: Run it
&lt;/h3&gt;

&lt;p&gt;In two terminals (better with &lt;a href="https://github.com/tmux/tmux/wiki" rel="noopener noreferrer"&gt;tmux&lt;/a&gt;), run the following:&lt;/p&gt;

&lt;p&gt;In the first terminal:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx tsx server.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;And in the other:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx tsx client.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This is the kinda output you'd get:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;connected, negotiated protocol: 2026-07-28

tools/list: list_deployments, server_stats, deploy
cache hints: ttlMs=30000 cacheScope=public

second listTools served from cache
list_deployments: No deployments yet.

[elicitation] server asks: "Deploy to prod? This will go live." -&amp;gt; answering yes
deploy: Deployed to prod at 2026-08-01T08:02:45.601Z

server_stats: pid=159984 requestsServed=6
list_deployments: prod @ 2026-08-01T08:02:45.601Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Every line here demonstrates a spec feature, and I designed it that way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;2026-07-28&lt;/code&gt;: we're on the new protocol, not the legacy fallback&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ttlMs=30000&lt;/code&gt; + &lt;code&gt;served from cache&lt;/code&gt;: the second &lt;code&gt;listTools()&lt;/code&gt; never touched the network&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The elicitation line, then the deploy&lt;/strong&gt;: that was two &lt;code&gt;tools/call&lt;/code&gt; POSTs. First one returned &lt;code&gt;input_required&lt;/code&gt; and closed. Second had the answer plus the sealed &lt;code&gt;requestState&lt;/code&gt;. No stream was ever held open.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;requestsServed=6&lt;/code&gt;: six requests, six fresh server instances, one process. Under a load balancer, those six could've hit six different machines. How cool is that?&lt;/li&gt;
&lt;li&gt;The final &lt;code&gt;list_deployments&lt;/code&gt;: app state survived even though protocol state didn't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the math is here: 4 tool calls, plus 2 &lt;code&gt;listTools()&lt;/code&gt; where only 1 hit the wire, plus 1 extra round for the MRTR retry = 6 server builds.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foopkg0th0lfdarmt41rx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foopkg0th0lfdarmt41rx.png" alt="tool call output" width="800" height="285"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 6: Look at the raw wire
&lt;/h3&gt;

&lt;p&gt;Let's see the "no handshake" thing. One bare curl, with no initialization:&lt;/p&gt;

&lt;p&gt;Run the following command:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://127.0.0.1:3000/mcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Accept: application/json, text/event-stream"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"MCP-Protocol-Version: 2026-07-28"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Mcp-Method: tools/call"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Mcp-Name: list_deployments"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_deployments","arguments":{},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"curl","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;blockquote&gt;
&lt;p&gt;btw, this curl command was suggested by Claude.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here's the result you get back:&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;"result"&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;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"prod @ 2026-08-01T08:02:45.601Z"&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;"resultType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"complete"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"_meta"&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;"io.modelcontextprotocol/serverInfo"&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"updated-mcp-spec-bot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.0.0"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="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;Two things worth noticing in that response.&lt;/p&gt;

&lt;p&gt;The result already shows the prod deployment, because I ran this curl against the same server process the client just deployed through.&lt;/p&gt;

&lt;p&gt;A totally separate client, no handshake, no session, and it reads the record the TypeScript client wrote. App state persists, protocol state doesn't.&lt;/p&gt;

&lt;p&gt;Look at those headers. &lt;code&gt;Mcp-Method&lt;/code&gt; and &lt;code&gt;Mcp-Name&lt;/code&gt; are right there for your gateway to route on. And the &lt;code&gt;_meta&lt;/code&gt; makes the request fully self-describing.&lt;/p&gt;

&lt;p&gt;The DX here is genuinely good.&lt;/p&gt;


&lt;h2&gt;
  
  
  Deploying to Cloudflare Workers
&lt;/h2&gt;

&lt;p&gt;We're deploying this to Cloudflare Workers, and it costs nothing: the free plan gives you 100,000 requests a day and a &lt;code&gt;*.workers.dev&lt;/code&gt; subdomain, no credit card needed.&lt;/p&gt;

&lt;p&gt;Why Workers? Because it's the natural way for a stateless MCP server. &lt;code&gt;createMcpHandler&lt;/code&gt; returns a fetch-style handler, and fetch handlers are literally what Workers runs. The entire platform difference fits in one tiny file.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ Cloudflare has quick-start MCP templates (&lt;code&gt;npm create cloudflare -- --template=cloudflare/ai/demos/remote-mcp-authless&lt;/code&gt;). As of writing, Cloudflare's own docs warn that these still scaffold the deprecated &lt;code&gt;McpAgent&lt;/code&gt; path and say, "Do not use that path for a new server." It's the old stateful world, and it doesn't speak 2026-07-28. Skip the template. &lt;code&gt;createMcpHandler&lt;/code&gt; is the recommended path, and it's what we're already using.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  Step 1: The Worker entry
&lt;/h3&gt;

&lt;p&gt;Create &lt;code&gt;worker.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 👇 worker.ts&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;Hono&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;hono&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;createMcpHandler&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/server&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;buildServer&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;./bot.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;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createMcpHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buildServer&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;app&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;Hono&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/healthz&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="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="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/mcp&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="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;handler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Eleven lines. Same &lt;code&gt;buildServer&lt;/code&gt;, same tools, same MRTR flow.&lt;/p&gt;

&lt;p&gt;Two deliberate differences from the Node entry:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Plain &lt;code&gt;new Hono()&lt;/code&gt; instead of &lt;code&gt;createMcpHonoApp()&lt;/code&gt;. The Host validation in &lt;code&gt;createMcpHonoApp&lt;/code&gt; is DNS rebinding protection &lt;em&gt;for&lt;/em&gt; localhost &lt;em&gt;servers&lt;/em&gt;. Behind Cloudflare's edge, it just gets in the way.&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;serve(...)&lt;/code&gt;. Workers calls your exported fetch handler itself.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Step 2: The wrangler config
&lt;/h3&gt;

&lt;p&gt;Wrangler is Cloudflare's CLI for Workers. It bundles your TypeScript (no build step needed), runs it locally on the real production runtime, manages secrets, and deploys.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; wrangler
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Create &lt;code&gt;wrangler.jsonc&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"updated-mcp-spec-bot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"main"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"worker.ts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"compatibility_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"compatibility_flags"&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;"nodejs_compat"&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;nodejs_compat&lt;/code&gt; flag fills in Node-ish globals so npm packages behave.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 3: Test on the real runtime, locally
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx wrangler dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This runs &lt;code&gt;worker.ts&lt;/code&gt; on &lt;strong&gt;workerd&lt;/strong&gt;, the same engine that runs in Cloudflare production, at &lt;code&gt;http://localhost:8787&lt;/code&gt;. Point the client at it:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;MCP_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:8787/mcp npx tsx client.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Same full output as the Node run: &lt;code&gt;2026-07-28&lt;/code&gt; negotiated, cache hints, the MRTR deploy round-trip. Except one line:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;server_stats&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pid=1 requestsServed=6&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;pid=1&lt;/code&gt;. That's the edge runtime saying hello. 🫡&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 4: Deploy it
&lt;/h3&gt;

&lt;p&gt;Create a free account at &lt;a href="https://dash.cloudflare.com/sign-up" rel="noopener noreferrer"&gt;dash.cloudflare.com/sign-up&lt;/a&gt; if you don't have one, then:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx wrangler login
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Set the production &lt;code&gt;requestState&lt;/code&gt; secret (this is the shared HMAC key, so every edge instance can verify tokens minted by any other):&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 32          &lt;span class="c"&gt;# copy the output&lt;/span&gt;
npx wrangler secret put STATE_KEY   &lt;span class="c"&gt;# paste it when prompted&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0ztb609ovf6yanabtixu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0ztb609ovf6yanabtixu.png" alt="wrangler setup" width="797" height="125"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ One gotcha from my own run: the key must be at least 32 bytes or the codec throws at startup. &lt;code&gt;openssl rand -hex 32&lt;/code&gt; gives you 64 hex characters, which is plenty.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And ship it:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx wrangler deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;First deploy asks you to pick your free &lt;code&gt;workers.dev&lt;/code&gt; subdomain. Ten seconds later:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://updated-mcp-spec-2026.&amp;lt;your-subdomain&amp;gt;.workers.dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Your MCP server is live on Cloudflare's global edge.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxcja7s8fxhktca8wy70o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxcja7s8fxhktca8wy70o.png" alt="mcp server in cloudflare" width="798" height="262"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 5: Verify from the outside
&lt;/h3&gt;

&lt;p&gt;Run the Step 6 curl against the public URL (just swap the host), hit &lt;code&gt;/healthz&lt;/code&gt; in a browser, and then the real proof:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;MCP_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://updated-mcp-spec-2026.&amp;lt;your-subdomain&amp;gt;.workers.dev/mcp npx tsx client.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Same output. Except now it's on the internet.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzdjl92s674r1jubmq1fn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzdjl92s674r1jubmq1fn.png" alt="remote mcp server" width="800" height="177"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Connect a real agent to it, with no tunnel and no ngrok:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add &lt;span class="nt"&gt;--transport&lt;/span&gt; http updated-mcp-spec-2026 &lt;span class="se"&gt;\&lt;/span&gt;
  https://updated-mcp-spec-bot.&amp;lt;your-subdomain&amp;gt;.workers.dev/mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Run &lt;code&gt;/mcp&lt;/code&gt; in a Claude Code session to see it connected, then ask it to "deploy to staging". Since Claude Code doesn't declare the elicitation capability yet, our capability-aware fallback kicks in: the tool tells the agent to confirm with you first, you say yes in chat, and the deploy lands.&lt;/p&gt;

&lt;p&gt;Bonus: run &lt;code&gt;npx wrangler tail&lt;/code&gt; while you do it and watch the requests land in your production logs live.&lt;/p&gt;

&lt;p&gt;Here’s a small demo:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/9xQ5RmU-Hrc"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;
&lt;h3&gt;
  
  
  One caveat
&lt;/h3&gt;

&lt;p&gt;Our &lt;code&gt;deployments&lt;/code&gt; array lives in memory, and on Workers, memory is extra ephemeral: isolates spin up and down per location, so two requests might see different histories. That's not a bug in the demo; it's the whole lesson of the spec, one more time. Protocol state is gone by design, and app state belongs in real storage. On Cloudflare, that's KV, D1, or Durable Objects.&lt;/p&gt;


&lt;h2&gt;
  
  
  Where Composio fits
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5wnytjebt7lcoblnckp8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5wnytjebt7lcoblnckp8.png" alt="Composio" width="799" height="205"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What we just built is one server with three tools. Real agents need Gmail, Slack, Notion, GitHub, Linear, and fifty other things.&lt;/p&gt;

&lt;p&gt;You could build and deploy a similar server for every one of those. Handle each app's OAuth. Keep up with every API change. Run all that infra.&lt;/p&gt;

&lt;p&gt;Or you point your agent at Composio, which gives you 1000+ apps behind a single MCP endpoint:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;https://connect.composio.dev/mcp&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Build custom MCP servers (like the one we built) for your own domain logic, and let Composio be the app layer for everything else.&lt;/p&gt;


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

&lt;p&gt;The 2026-07-28 spec is a breaking release, and it's the good kind of breaking.&lt;/p&gt;

&lt;p&gt;MCP servers are now boring HTTP services. Deploy them like you deploy everything else: stateless, load-balanced, cacheable, autoscaled. The handshake is gone, sessions are gone, and interactive tools work anyway thanks to MRTR.&lt;/p&gt;

&lt;p&gt;If you're starting a new server today: use SDK v2, use the &lt;code&gt;createMcpHandler(buildServer)&lt;/code&gt; factory pattern, keep resources at module level, seal your &lt;code&gt;requestState&lt;/code&gt;, and split your logic from your platform entry. We went from localhost to Cloudflare's global edge with an eleven-line file, and the same split works for Railway, Render, Fly, or a plain VM through &lt;code&gt;server.ts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you have existing servers: you've got a 12-month window on everything deprecated. Use it.&lt;/p&gt;

&lt;p&gt;Now your MCP server finally gets to be just another web service. 🤌&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr11gknb9xy4kfo0eq31y.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr11gknb9xy4kfo0eq31y.gif" alt="noiceee" width="450" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;ℹ️ You can find the entire source code in &lt;a href="https://github.com/shricodev/updated-2026-july-mcp-spec-tut" rel="noopener noreferrer"&gt;the repository&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;


&lt;div class="ltag__user ltag__user__id__1127015"&gt;
    &lt;a href="/shricodev" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1127015%2F1c5e48a2-f602-4e7d-8312-3c0322d155c6.jpg" alt="shricodev image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/shricodev"&gt;Shrijal Acharya&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/shricodev"&gt;SDE • GOLD @Microsoft Student Ambassador • Prev Lead Collab and Dev-Team Lead @oppiaorg • Mail for collaboration&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;



</description>
      <category>mcp</category>
      <category>tutorial</category>
      <category>ai</category>
      <category>automation</category>
    </item>
    <item>
      <title>How to connect MCP servers to Slackbot</title>
      <dc:creator>Shrijal Acharya</dc:creator>
      <pubDate>Sat, 18 Jul 2026 11:57:47 +0000</pubDate>
      <link>https://dev.to/composiodev/how-to-connect-mcp-servers-to-slackbot-1al4</link>
      <guid>https://dev.to/composiodev/how-to-connect-mcp-servers-to-slackbot-1al4</guid>
      <description>&lt;p&gt;Slackbot recently added support for MCP, which means you can now connect it with external apps and let it take actions across your work tools directly from Slack&lt;/p&gt;

&lt;p&gt;But the native app list is still limited. By the time of writing this post, there's just about &lt;strong&gt;20 apps&lt;/strong&gt; that you can connect from the &lt;a href="https://slack.com/marketplace" rel="noopener noreferrer"&gt;Slack marketplace&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;And for most teams, that's not enough.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqlyug2z5afeztlhxrwtd.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqlyug2z5afeztlhxrwtd.gif" alt="not enough gif" width="480" height="240"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;But luckily, Slack allows you to set up or use your custom MCP servers and not have to be limited by the number of apps available in marketplace.&lt;/p&gt;

&lt;p&gt;That's where &lt;a href="https://composio.dev/" rel="noopener noreferrer"&gt;Composio&lt;/a&gt; helps you. It can connect your slack bots to &lt;strong&gt;1000+ apps&lt;/strong&gt; that you can use.&lt;/p&gt;

&lt;p&gt;In this guide, we’ll go through how to connect Slackbot with Composio’s MCP server in 3 steps.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;ℹ️ The steps will be pretty much the same with other MCP servers as well.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What We're Building
&lt;/h2&gt;

&lt;p&gt;Once this is set up, you can ask Slackbot things like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Find my latest unread Gmail emails.
Search my Notion workspace for launch notes.
Check my Google Calendar for meetings tomorrow.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;And a bunch more. Imagine all the stuff you can do with 1000+ apps. 😵‍💫&lt;/p&gt;

&lt;p&gt;I'll leave the rest to your imagination...&lt;/p&gt;

&lt;p&gt;Slackbot sends the request to Composio Connect, Composio finds the right tool, asks you to connect the app if needed (one time), and then executes the action.&lt;/p&gt;


&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;Before we begin, make sure you have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Slack workspace with Slackbot MCP client access (comes with Business+ and Enterprise plan)&lt;/li&gt;
&lt;li&gt;Permission to create or configure a Slack app.&lt;/li&gt;
&lt;li&gt;A Composio account.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's it.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 1: Get the Composio Connect MCP URL
&lt;/h2&gt;

&lt;p&gt;First, you need the MCP server URL from Composio.&lt;/p&gt;

&lt;p&gt;For this setup, use Composio Connect:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;https://connect.composio.dev/mcp&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This is Composio’s hosted MCP server that gives your AI agent access to 1,000+ apps with just &lt;strong&gt;7 meta-tools&lt;/strong&gt; that let the slackbot discover what's available, authorize apps on demand, and execute tools across apps in parallel through a single connection.&lt;/p&gt;

&lt;p&gt;You don’t need to create a custom MCP server for this guide.&lt;/p&gt;

&lt;p&gt;Composio also supports custom MCP servers for more scoped project-specific use cases, but those can require API-key-based auth. For Slackbot, Composio Connect is the simpler path because it works with OAuth-based MCP client flows.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9r6it1xz4hl4h3ifel7a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9r6it1xz4hl4h3ifel7a.png" alt="Composio Connect" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 2: Add Composio Connect to a Slack App
&lt;/h2&gt;

&lt;p&gt;Now, we need to register the Composio MCP server inside a Slack app.&lt;/p&gt;

&lt;p&gt;Go to the Slack developer dashboard and create a new app.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5kkqe4kem5xnfmpyla6r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5kkqe4kem5xnfmpyla6r.png" alt="Slack new app creation" width="800" height="432"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once the app is created:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open your Slack app.&lt;/li&gt;
&lt;li&gt;In the left sidebar, go to &lt;strong&gt;Features&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;MCP Servers&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwvranutnruw5ja1w5vhm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwvranutnruw5ja1w5vhm.png" alt="Slack MCP Servers button" width="800" height="432"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Click &lt;strong&gt;Get Started&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff2yx87053fw9upha8l28.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff2yx87053fw9upha8l28.png" alt="Slack get started button" width="800" height="434"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now fill in the MCP server details.&lt;/p&gt;

&lt;p&gt;Use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Name&lt;/strong&gt;: Composio (or anything you wish)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;URL&lt;/strong&gt;: &lt;a href="https://connect.composio.dev/mcp" rel="noopener noreferrer"&gt;https://connect.composio.dev/mcp&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth Type&lt;/strong&gt;: Dynamic Client Registration&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnyzro2ycp7xjqhb3gsnw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnyzro2ycp7xjqhb3gsnw.png" alt="Slack Add MCP" width="800" height="434"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For the auth type, select &lt;strong&gt;Dynamic Client Registration&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;ℹ️ This is the right option for MCP servers that support OAuth discovery and client registration. Slack handles the client registration automatically, so you don’t need to manually create OAuth credentials first.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Now, if you click the three dots and then &lt;strong&gt;Tools&lt;/strong&gt;, you should see that it currently cannot fetch the tools because the MCP server uses a dynamic connection and must be installed in your workspace first.&lt;/p&gt;

&lt;p&gt;So, now head over to the &lt;strong&gt;Install App&lt;/strong&gt; tab, and install it to the workspace you selected when creating the app.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxwv4sjhjmjvmkiflvhyp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxwv4sjhjmjvmkiflvhyp.png" alt="Slack Install App" width="800" height="434"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If your workspace requires approval, send the app request to your admin.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 3: Connect Composio inside Slackbot
&lt;/h2&gt;

&lt;p&gt;Once your Slack app is installed and approved, open a DM with Slackbot.&lt;/p&gt;

&lt;p&gt;Then, just type in a prompt that requires using the app, Slack will use the correct app automatically for you.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvp5wb4l3ab4anrfqv8y9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvp5wb4l3ab4anrfqv8y9.png" alt="Slack connecting composio" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click &lt;strong&gt;Connect,&lt;/strong&gt; and you’ll be taken to a confirmation page.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4f5nvtnc0j1x1wkbv356.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4f5nvtnc0j1x1wkbv356.png" alt="Slack connecting composio confirmation" width="800" height="489"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click on Continue, and then confirm it on the Composio end.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fljriezhxmemb38w8apqz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fljriezhxmemb38w8apqz.png" alt="Composio confirmation" width="800" height="489"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If everything went well, you should see that your account is connected.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F58g6dsw3n767nztxc4mo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F58g6dsw3n767nztxc4mo.png" alt="Slack final confirmation" width="799" height="293"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After that, Slackbot should be able to discover Composio’s MCP tools.&lt;/p&gt;

&lt;p&gt;Start with a simpler test:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;What tools are available from Composio?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Once that goes through, now try an actual app action.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Send a mail to x@y.com saying 'Hi, from Composio 👋 inside Slackbot'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;If Gmail is not connected yet, Composio should generate an OAuth link for you to connect it. Once you approve it, the connection persists for future use. So, you don't have to repeat this step again and again.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5fb169cfpktm2xynzoe3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5fb169cfpktm2xynzoe3.png" alt="Composio connection link" width="800" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Slackbot may ask you to approve the action before it writes data to another app.&lt;/p&gt;

&lt;p&gt;That's expected. Once connected, Slackbot can use that app through Composio.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmxlw14j7ed5fi6h0o6dk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmxlw14j7ed5fi6h0o6dk.png" alt="Composio MCP in action" width="800" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Voilà, you've successfully connected Slackbot to Composio MCP. 🎊&lt;/p&gt;

&lt;p&gt;Here’s a quick workflow for initiating a connection and running an actual app action:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/m6kv3tqjUgU"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;


&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Slack's own marketplace is good, and if it covers all the tools you require, you can completely stick to it.&lt;/p&gt;

&lt;p&gt;But for some of you, that's simply not enough. I hope this helps overcome that problem.&lt;/p&gt;

&lt;p&gt;So instead of jumping between different tools, you can ask Slackbot to find information, create tasks, update records, and run actions across your apps from inside Slack.&lt;/p&gt;

&lt;p&gt;This is a much-needed quality-of-life improvement for teams that already live in Slack.&lt;/p&gt;

&lt;p&gt;Slackbot gives you the interface. MCP gives you the protocol.&lt;/p&gt;

&lt;p&gt;And Composio gives you the app layer. 👌&lt;/p&gt;


&lt;div class="ltag__user ltag__user__id__1127015"&gt;
    &lt;a href="/shricodev" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1127015%2F1c5e48a2-f602-4e7d-8312-3c0322d155c6.jpg" alt="shricodev image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/shricodev"&gt;Shrijal Acharya&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/shricodev"&gt;SDE • GOLD @Microsoft Student Ambassador • Prev Lead Collab and Dev-Team Lead @oppiaorg • Mail for collaboration&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;



</description>
      <category>ai</category>
      <category>productivity</category>
      <category>beginners</category>
      <category>automation</category>
    </item>
    <item>
      <title>Claude Opus vs Kombai in 3 Real-World Frontend AI Tests 🚀</title>
      <dc:creator>Shrijal Acharya</dc:creator>
      <pubDate>Tue, 02 Jun 2026 16:06:41 +0000</pubDate>
      <link>https://dev.to/shricodev/claude-opus-vs-kombai-in-3-real-world-frontend-ai-tests-13a2</link>
      <guid>https://dev.to/shricodev/claude-opus-vs-kombai-in-3-real-world-frontend-ai-tests-13a2</guid>
      <description>&lt;p&gt;Frontend automation has been getting pretty wild lately. 🫠&lt;/p&gt;

&lt;p&gt;A few months ago, this comparison would have been much easier to frame.&lt;/p&gt;

&lt;p&gt;On one side, you had the &lt;strong&gt;Claude Opus&lt;/strong&gt; lineup, one of the strongest coding model lineups available, running through tools like Claude Code with Figma MCP support.&lt;/p&gt;

&lt;p&gt;On the other side, you had &lt;strong&gt;Kombai&lt;/strong&gt;, a frontend-focused AI coding agent that was mostly known for turning Figma files, screenshots, prompts, and existing designs into clean frontend code.&lt;/p&gt;

&lt;p&gt;But Kombai has changed quite a bit since then.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fu5fj5j52sacbg5j1fgix.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fu5fj5j52sacbg5j1fgix.gif" alt="spiderman meme" width="498" height="278"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It is not just an AI coding agent for frontend anymore. With the newer updates, especially &lt;strong&gt;Design Mode&lt;/strong&gt;, Kombai is now trying to cover a much bigger part of the frontend workflow.&lt;/p&gt;

&lt;p&gt;That changes the comparison a little.&lt;/p&gt;

&lt;p&gt;If we only compare it on code generation, we are kind of testing just one part of what Kombai does now. It can also help create UI designs, iterate on them visually, and then move those designs into code inside the same IDE workflow.&lt;/p&gt;

&lt;p&gt;I will go deeper into that later in the Kombai section, because there is quite a bit to talk about there.&lt;/p&gt;

&lt;p&gt;For this post, though, I still want to focus mainly on the part that matters most in real projects:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🤔 Can it work inside an existing codebase and ship good frontend changes without breaking things?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Because at the end of the day, that's the actual pain point of frontend.&lt;/p&gt;

&lt;p&gt;If you’re interested, we already compared the older version of &lt;strong&gt;Kombai&lt;/strong&gt; and &lt;strong&gt;Figma MCP&lt;/strong&gt; here: &lt;a href="https://www.freecodecamp.org/news/figma-mcp-vs-kombai-frontend-clone-comparison" rel="noopener noreferrer"&gt;Figma MCP vs Kombai&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So in this post, we’ll look at what has changed with Kombai, touch on Design Mode, and then compare it with Claude Opus on 3 real open-source projects.&lt;/p&gt;

&lt;p&gt;Let’s find out which one actually feels more useful. 👇&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi9zgn89g85nez1b5u6x2.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi9zgn89g85nez1b5u6x2.gif" alt="Bobs Burger on Fire" width="500" height="284"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;If you just want the takeaway, here’s the quick rundown:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Kombai is not just a Figma-to-code tool anymore.&lt;/strong&gt; It is now more of an all-in-one design and frontend coding agent. You can generate designs, iterate on a canvas, visually edit UI, use Figma as input, work with your real codebase, and then ship great frontend code from inside your IDE.&lt;/li&gt;
&lt;/ul&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Design Mode is the biggest new change.&lt;/strong&gt; It gives Kombai an infinite canvas, Style Guides, Themes, reusable Blocks, CSS-level visual editing, and a one-click flow from design to code. It is still early, but it changes how you should think about Kombai.&lt;/li&gt;
&lt;/ul&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;For the coding tests in this post, Kombai was still clearly stronger overall.&lt;/strong&gt; In real open-source projects, it handled frontend implementation, codebase understanding, UI quality, and integration better than Claude Opus in most cases.&lt;/li&gt;
&lt;/ul&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Claude Opus 4.6 with Figma MCP is still totally fine&lt;/strong&gt;, especially if you already work mostly in the CLI with tools like Claude Code, Codex, or whatever else. You can absolutely stick with that setup and still get solid results. It works well.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The bigger point is this:&lt;/p&gt;

&lt;p&gt;If you are already comfortable in the CLI, there is no real reason to switch just for this. A good model with Figma MCP is more than enough for a lot of use cases.&lt;/p&gt;

&lt;p&gt;But if you use &lt;strong&gt;Cursor&lt;/strong&gt;, &lt;strong&gt;VS Code&lt;/strong&gt;, &lt;strong&gt;Windsurf&lt;/strong&gt;, &lt;strong&gt;Trae&lt;/strong&gt;, &lt;strong&gt;Antigravity&lt;/strong&gt;, &lt;strong&gt;Kiro&lt;/strong&gt;, or another GUI IDE, you should seriously try Kombai at least once.&lt;/p&gt;

&lt;p&gt;It is just a very good tool for frontend work.&lt;/p&gt;

&lt;p&gt;I’ve been using Kombai for more than &lt;strong&gt;10 months&lt;/strong&gt;, and it still feels kind of wild sometimes. Honestly, I have &lt;strong&gt;Cursor&lt;/strong&gt; installed on my machine for Kombai alone. That’s how useful it has been for me.&lt;/p&gt;




&lt;h2&gt;
  
  
  Brief on Kombai
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;💁 Kombai is an all-in-one design and frontend coding agent for building production-ready frontends&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Kombai used to be easy to describe as a frontend AI agent.&lt;/p&gt;

&lt;p&gt;This is still true, but now its just a small part of what it can do.&lt;/p&gt;

&lt;p&gt;The better way to describe it today is this:&lt;/p&gt;

&lt;p&gt;It's an agent specially designed for frontend unlike usual coding agents like Claude Code, Codex or anything else.&lt;/p&gt;

&lt;p&gt;That specialization shows up in a few places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It understands frontend stacks and component libraries&lt;/li&gt;
&lt;li&gt;It can parse Figma designs natively&lt;/li&gt;
&lt;li&gt;It can work with existing components, tokens, hooks, and design systems&lt;/li&gt;
&lt;li&gt;It can visually inspect and edit the browser output&lt;/li&gt;
&lt;li&gt;It can generate UI designs before writing code&lt;/li&gt;
&lt;li&gt;It focuses more on production-ready frontend changes, and not a generic app generation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The biggest recent update is the &lt;strong&gt;Design Mode&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;With Design Mode, Kombai can generate UI designs directly inside your IDE. You can start from a prompt, image, website, or Figma reference, and Kombai creates editable UI designs that you can change visually.&lt;/p&gt;

&lt;p&gt;Here’s a quick demo of Kombai’s Design Mode in action by &lt;a href="https://www.youtube.com/@beau" rel="noopener noreferrer"&gt;Beau Carnes&lt;/a&gt; (core team member of &lt;strong&gt;freeCodeCamp&lt;/strong&gt;).&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/iaIiP2lr0UI"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;It also has proper design-system-ish primitives now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Style Guides&lt;/strong&gt; for controlling the overall visual direction&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Themes&lt;/strong&gt; for reusable design tokens&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blocks&lt;/strong&gt; for reusable design elements&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Variants&lt;/strong&gt; for trying multiple design directions&lt;/li&gt;
&lt;li&gt;A CSS editor for visual tweaks&lt;/li&gt;
&lt;li&gt;and many more...&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These designs live as &lt;code&gt;.canvas&lt;/code&gt; files in your repo, which means they can be version-controlled with git like your code.&lt;/p&gt;

&lt;p&gt;Once you like a design, you can hit Code design, and Kombai moves it into Code Mode, reads your project, understands your stack, reuses relevant components and tokens, and codes it into working frontend.&lt;/p&gt;

&lt;p&gt;That is what makes it feel different from tools like v0, Lovable, or Bolt.&lt;/p&gt;

&lt;p&gt;Those tools are great for generating prototypes or apps from scratch. Kombai is more focused on your &lt;strong&gt;existing frontend repo&lt;/strong&gt;. It works inside your editor and tries to build the thing using the stack you already have.&lt;/p&gt;

&lt;p&gt;Check out this intro demo Kombai 2.0. The first AI design engineer. &lt;/p&gt;

&lt;p&gt;&lt;iframe class="tweet-embed" id="tweet-2061825199247614316-192" src="https://platform.twitter.com/embed/Tweet.html?id=2061825199247614316"&gt;
&lt;/iframe&gt;

  // Detect dark theme
  var iframe = document.getElementById('tweet-2061825199247614316-192');
  if (document.body.className.includes('dark-theme')) {
    iframe.src = "https://platform.twitter.com/embed/Tweet.html?id=2061825199247614316&amp;amp;theme=dark"
  }



&lt;/p&gt;

&lt;p&gt;With Kombai, you can not only create designs, but also get the safety of knowing that no backend code is &lt;strong&gt;ever touched&lt;/strong&gt;, which ensures your business logic is not mistakenly changed.&lt;/p&gt;

&lt;p&gt;You can add it right inside your editor. It works with &lt;strong&gt;VSCode&lt;/strong&gt;, &lt;strong&gt;Cursor&lt;/strong&gt;, &lt;strong&gt;Windsurf&lt;/strong&gt;, and &lt;strong&gt;Trae&lt;/strong&gt;. Just grab it from the extension marketplace, launch it, and you’re ready to go.&lt;/p&gt;

&lt;p&gt;With Kombai, you can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Generate UI designs from scratch using Design Mode.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Turn Figma designs into code without setting up Figma MCP separately.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Turn Figma designs into code (React, HTML, CSS, etc.) using the component library your project already uses.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Use screenshots, images, websites, or natural language as input.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Reuse existing components, hooks, design tokens, and frontend conventions from your repo.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Visually inspect and edit your UI through the Kombai Browser.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Work with a frontend-smart engine that understands 30+ libraries, including Next.js, MUI, and Chakra UI.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Most importantly, preview the changes in a sandbox so you can approve or reject the change before committing it to the files.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Head to the &lt;a href="https://docs.kombai.com/get-started/welcome" rel="noopener noreferrer"&gt;docs&lt;/a&gt; to get started and find the setup for your editor.&lt;/p&gt;

&lt;p&gt;You can be up and running in under a minute:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Install the extension for your editor&lt;/li&gt;
&lt;li&gt;Sign in and connect your project&lt;/li&gt;
&lt;li&gt;Pick the mode you need: Code, Plan, Design, etc.&lt;/li&gt;
&lt;li&gt;Paste a Figma link or describe what you want to build&lt;/li&gt;
&lt;li&gt;Paste a Figma link, describe what you want, attach an image, or work from your existing code&lt;/li&gt;
&lt;li&gt;Review the output and commit your code&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you spend most of your time on the frontend, this is a &lt;strong&gt;no-brainer&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Now comes with 100 award winning landing pages that you can make your own.&lt;/p&gt;

&lt;p&gt;&lt;iframe class="tweet-embed" id="tweet-2061825201730695550-647" src="https://platform.twitter.com/embed/Tweet.html?id=2061825201730695550"&gt;
&lt;/iframe&gt;

  // Detect dark theme
  var iframe = document.getElementById('tweet-2061825201730695550-647');
  if (document.body.className.includes('dark-theme')) {
    iframe.src = "https://platform.twitter.com/embed/Tweet.html?id=2061825201730695550&amp;amp;theme=dark"
  }



&lt;/p&gt;




&lt;h2&gt;
  
  
  One Important Note Before the Tests
&lt;/h2&gt;

&lt;p&gt;Kombai now has a much bigger features and design support than it did before.&lt;/p&gt;

&lt;p&gt;But this post is still mainly a frontend coding and implementation comparison.&lt;/p&gt;

&lt;p&gt;So, I am not going to deeply test Design Mode here. That deserves its own separate post because the right comparison there would probably be against tools like v0, Lovable, Bolt, and maybe Figma-based workflows.&lt;/p&gt;

&lt;p&gt;For this post, the test is still:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Can it understand a real codebase?&lt;/li&gt;
&lt;li&gt;Can it preserve functionality?&lt;/li&gt;
&lt;li&gt;Can it implement a feature cleanly?&lt;/li&gt;
&lt;li&gt;Can it match or improve UI quality?&lt;/li&gt;
&lt;li&gt;Importantly, can it work in a large real-world existing project?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is exactly what we are going to test here.&lt;/p&gt;




&lt;h2&gt;
  
  
  Test Workflow
&lt;/h2&gt;

&lt;p&gt;In this model test, I’ll be using &lt;strong&gt;Claude Opus 4.6&lt;/strong&gt; with everyone’s favorite CLI coding agent, &lt;strong&gt;Claude Code&lt;/strong&gt;, along with Figma MCP support.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💁 Just in case you're interested in how to add MCP support to Claude Code, you can view the guide &lt;a href="https://code.claude.com/docs/en/mcp" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For Kombai, the choice mostly comes down to the IDE, so I’ll go with Cursor. It does not really matter much which IDE you use, though. VS Code would work just fine too.&lt;/p&gt;

&lt;p&gt;We’ll test both tools on three decently complex tasks in &lt;strong&gt;real-world open-source projects&lt;/strong&gt; with hundreds of thousands of LOC.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A frontend-heavy task with Figma&lt;/li&gt;
&lt;li&gt;A frontend + backend task with more implementation complexity&lt;/li&gt;
&lt;li&gt;A task that relies more on codebase understanding than implementation complexity&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I’ll compare both tools on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;time to complete the task&lt;/li&gt;
&lt;li&gt;quality of the code&lt;/li&gt;
&lt;li&gt;how closely the final output matches the given design or feature intent&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Note:&lt;/strong&gt; I’ll share the source code changes for each task from both tools in a &lt;code&gt;.patch&lt;/code&gt; file. That way, you can easily reproduce them on your local system by cloning the repository and applying the patch with &lt;code&gt;git apply &amp;lt;path_file_name&amp;gt;&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; As I’m using a Claude plan and not API usage, price is roughly estimated based on the output tokens.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Real-World Coding Comparison
&lt;/h2&gt;

&lt;p&gt;The entire test in this blog is going to be on top of real-world open-source projects that are used by thousands of people, not toy projects, but ones with &lt;strong&gt;thousands of LOC&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;All three tests are going to be on 3 different projects, all open-source, of course!&lt;/p&gt;

&lt;p&gt;Let's start with an easier one, a shadcn template repository with Figma MCP.&lt;/p&gt;




&lt;h2&gt;
  
  
  Test 1: Rebuild an Open-Source Project UI (with Figma MCP)
&lt;/h2&gt;

&lt;p&gt;For this test, I'll be comparing both of them on a Figma file, giving them access to a Figma MCP server.&lt;/p&gt;

&lt;p&gt;You can find the Figma design template for this test here: &lt;a href="https://www.figma.com/design/xfzmzKLBkvvY6BPteZWukK/-FREE--Food-Delivery-Admin-Dashboard--Community-?node-id=0-1&amp;amp;p=f&amp;amp;t=GmIYunPopZ2pO4SK-0" rel="noopener noreferrer"&gt;Dashboard&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Implement the provided Figma dashboard design in this existing Next.js + shadcn/ui dashboard codebase.

&amp;lt;figma_url&amp;gt;

Constraints:

1. Preserve all existing functionality exactly.
2. Do not break routing, state, existing interactions, or responsiveness.
3. Replace the current dashboard presentation layer with a UI that closely matches the Figma design.
4. The Figma is food-delivery themed. Translate only the content domain, not the visual system.
5. Keep the layout, structure, and styling language of the Figma as intact as possible.
6. Replace food-specific labels and data with content that fits a generic admin dashboard.
7. Reuse existing logic and data bindings wherever possible.
8. Avoid adding fake backend logic.
9. Keep the implementation production-quality and componentized.

Focus areas:

- sidebar
- header/top bar
- summary metric cards
- chart section
- table/list section
- filters/search/actions if present
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Claude Opus 4.6 (with Figma MCP)
&lt;/h3&gt;

&lt;p&gt;Here's the response from Claude Opus 4.6:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/LNB78jQO6f8"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;You can find the code it generated here: &lt;a href="https://gist.github.com/shricodev/ec49249394524a94139a5af16250f2df" rel="noopener noreferrer"&gt;Opus 4.6 source Code&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Opus did a pretty good job here.&lt;/p&gt;

&lt;p&gt;Given the Figma itself was not even made for a generic admin dashboard in the first place, there was obviously some room where it had to improvise a bit on its own.&lt;/p&gt;

&lt;p&gt;But the main thing I was testing for here was not just whether it could make the frontend look close enough. The actual ask in the prompt was to preserve and support the existing functionalities as well. That part just was not really there. It did not add the interactivity support properly, which was the whole point of the test after all.&lt;/p&gt;

&lt;p&gt;So visually, sure, it looks alright. But if the interaction layer is missing, that is a pretty big miss for this kind of task. If it had nailed that part too, this would have been a really strong result. But it didn’t.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Output token Cost:&lt;/strong&gt; ~$0.125&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duration:&lt;/strong&gt; 9 minutes 1 second&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Output Token Usage:&lt;/strong&gt; ~8K&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code Changes:&lt;/strong&gt; 6 files changed, 420 insertions(+), 422 deletions(-)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Faa8rfsl6zzzdn8ivt4us.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Faa8rfsl6zzzdn8ivt4us.png" alt="Opus 4.6 task completion" width="800" height="322"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Kombai
&lt;/h3&gt;

&lt;p&gt;Here's the response from Kombai:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/tlWNZd6_a84"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;You can find the code it generated here: &lt;a href="https://gist.github.com/shricodev/6b734610378fbbed6e524f137618d15f" rel="noopener noreferrer"&gt;Kombai source Code&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Kombai nailed this one. Even better how accurately it finds out the tech stack.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkld3s939tqgrvjtx43qg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkld3s939tqgrvjtx43qg.png" alt="Kombai figuring out tech stack" width="800" height="395"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The frontend turned out really good. More importantly, the interactivity is there, which is the main thing being tested other than how close it comes to the Figma design. It actually respected the fact that this is an existing app, with behavior that still needs to work.&lt;/p&gt;

&lt;p&gt;And if I compare both the UI itself and the overall build quality from a production POV, Kombai clearly did better here.&lt;/p&gt;

&lt;p&gt;That said, there was one issue I noticed while working with Kombai.&lt;/p&gt;

&lt;p&gt;After it finishes the implementation, it has this nice default feature where it opens up a browser preview and lets you chat there to fix smaller things quickly. In theory, that sounds great. In practice, for apps that require authentication, which this one did, it falls apart. Google OAuth simply flags it as unsafe, so you cannot log in there at all.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwj5n0kq6b23lyfgqfncy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwj5n0kq6b23lyfgqfncy.png" alt="Kombai authentication problem in the test browser" width="800" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So yeah, they definitely need to work on that.&lt;/p&gt;

&lt;p&gt;Still, overall, the user experience and the actual result were top notch.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Duration:&lt;/strong&gt; ~12 minutes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code Changes:&lt;/strong&gt; 12 files changed, 940 insertions(+), 532 deletions(-)&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;I noticed the project also has standalone Kanban board support, so why not quickly test it on this as well?&lt;/p&gt;

&lt;p&gt;Prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Completely refactor the Kanban board UI in this open source project based on this Figma:

&amp;lt;figma_url&amp;gt;

This should be a real UI redesign, not minor styling tweaks. Study the Figma closely and make the board feel much more polished, modern, clean, and cohesive. Improve layout, spacing, typography, hierarchy, cards, columns, controls, interaction states, and responsiveness.

Preserve functionality, but refactor components and styling where needed so the code is cleaner and the design is more consistent. Focus heavily on UI quality and make the final result feel much closer to the Figma overall.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Claude Opus 4.6 (with Figma MCP)
&lt;/h3&gt;

&lt;p&gt;Here's the response from Claude Opus 4.6:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/-hycEBfoaLw"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;You can find the code it generated here: &lt;a href="https://gist.github.com/shricodev/19c1e6f9f640975f22363f4ad9aa7346" rel="noopener noreferrer"&gt;Opus 4.6 source Code&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Opus did pretty well here too.&lt;/p&gt;

&lt;p&gt;The good part is that functionality was not broken. The board still works, and that matters a lot for a refactor like this. The redesign itself is also nice. It is clearly better than before.&lt;/p&gt;

&lt;p&gt;That said, if I look closely at the actual design match, it seems to miss a little here and there. It does not feel quite as locked in to the Figma as the best result should.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost:&lt;/strong&gt; negligible&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duration:&lt;/strong&gt; 5 minutes 26 seconds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code Changes:&lt;/strong&gt; 5 files changed, 162 insertions(+), 56 deletions(-)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fskrb2gjprh475bvt76t4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fskrb2gjprh475bvt76t4.png" alt="Ous 4.6 task completion" width="799" height="316"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Kombai
&lt;/h3&gt;

&lt;p&gt;Here's the response from Kombai:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/Qce9-dNZv_Q"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;You can find the code it generated here: &lt;a href="https://gist.github.com/shricodev/583d31c69823cb772c7a7188c4b89f73" rel="noopener noreferrer"&gt;Kombai source Code&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Again, Kombai was excellent here.&lt;/p&gt;

&lt;p&gt;Honestly, this one came out awesome. It matches the Figma really well, preserves the expected behavior, and feels like a proper redesign.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Code Changes:&lt;/strong&gt; 4 files changed, 362 insertions(+), 72 deletions(-)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duration:&lt;/strong&gt; ~6 minutes&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Test 2: Add a Feature in Uptime Kuma
&lt;/h2&gt;

&lt;p&gt;There's this open-source project that's super popular for the self-hosted monitoring service &lt;a href="https://github.com/louislam/uptime-kuma" rel="noopener noreferrer"&gt;Uptime Kuma&lt;/a&gt;, with over &lt;strong&gt;84K stars&lt;/strong&gt; on GitHub.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F41bm2kdpxp4q7gsrbgqt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F41bm2kdpxp4q7gsrbgqt.png" alt="Uptime Kuma" width="800" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's an existing issue on the project that we will try to build: &lt;a href="https://github.com/louislam/uptime-kuma/issues/1984" rel="noopener noreferrer"&gt;Calendar Graph&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Add a heatmap-style uptime history section to the public status page in this existing Uptime Kuma codebase.

Constraints:

1. Keep all existing functionality working.
2. Do not break the public status page, responsiveness, or current monitor behavior.
3. Build this as a proper feature inside the existing architecture.
4. Reuse existing logic and data flow wherever possible.
5. Avoid fake backend logic or hardcoded mock data.
6. Make the heatmap work independently for each service.
7. Keep the UI consistent with Uptime Kuma’s existing style.
8. Make the implementation clean and production-ready.

Focus areas:

- status page
- per-service uptime history
- heatmap UI
- state management
- backend integration
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Claude Opus 4.6 (with Figma MCP)
&lt;/h3&gt;

&lt;p&gt;Here's the response from Claude Opus 4.6:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/paQ4wQ2MyyU"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;You can find the code it generated here: &lt;a href="https://gist.github.com/shricodev/f6e66c056cd2bb1c868e640b25f2a953" rel="noopener noreferrer"&gt;Opus 4.6 source Code&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Opus actually got the overall feature in place, and to be fair, the core thing does work.&lt;/p&gt;

&lt;p&gt;That said, there is a bug.&lt;/p&gt;

&lt;p&gt;When you try to change the monitoring duration for one service, it also changes it for all the others. That is obviously not how this should behave, and for a feature like this, that kind of state handling bug is a big issue, of course!&lt;/p&gt;

&lt;p&gt;Other than that, there is not a whole lot to complain about. The core functionality works, just with some caveats, and that feels fair. I do not think it is realistic to expect the model to get every detail perfectly right in one shot on a non-trivial codebase.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Output token Cost:&lt;/strong&gt; ~$0.12&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duration:&lt;/strong&gt; 13 minutes 5 seconds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Output Token Usage:&lt;/strong&gt; ~7.5K&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code Changes:&lt;/strong&gt; 4 files changed, 784 insertions(+)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Kombai
&lt;/h3&gt;

&lt;p&gt;Here's the response from Kombai:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/UKF2VJ6dCyw"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;You can find the code it generated here: &lt;a href="https://gist.github.com/shricodev/423f45e00bf9e9c65e202992c8cf2a6f" rel="noopener noreferrer"&gt;Kombai source Code&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Kombai did this one properly.&lt;/p&gt;

&lt;p&gt;The feature is implemented correctly, the behavior is right, and even the bug that showed up in the Claude Opus implementation is not there at all.&lt;/p&gt;

&lt;p&gt;That part matters because this is not just about working on the UI. It's about putting the feature into an existing project.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Duration:&lt;/strong&gt; ~10 minutes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code Changes:&lt;/strong&gt; 4 files changed, 809 insertions(+), 1 deletion(-)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Test 3: Add a Feature to Chatwoot
&lt;/h2&gt;

&lt;p&gt;This is a little different from the other two tests.&lt;/p&gt;

&lt;p&gt;This was more of a test to see how well the model actually understands the codebase and less about generating or working on the UI heavily. The code change isn't going to be huge, but it tests the two on how good they are at understanding the codebase and adding a feature on top.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Far87ngghworzc90e6amb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Far87ngghworzc90e6amb.png" alt="Chatwoot" width="800" height="216"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Add a new "Participating" tab to the chat/conversation list in this existing Chatwoot codebase.

The goal is to let users quickly view conversations they are participating in, while keeping the implementation fully aligned with how Chatwoot already handles conversations, tabs, filtering, permissions, and dashboard state.

Constraints:

1. Preserve all existing functionality exactly.
2. Do not break existing conversation list behavior, routing, filters, permissions, or dashboard interactions.
3. Add "Participating" as a proper tab in the existing chat list UI, not as a separate temporary view.
4. Make sure the tab only shows conversations the current user is participating in.
5. Reuse existing backend, frontend, and store patterns wherever possible.
6. Avoid hacks, fake data, or disconnected logic.
7. Keep the implementation production-quality and consistent with the current Chatwoot UI.
8. Ensure permissions and visibility rules continue to work correctly.
9. Make the feature feel like a native part of the product.

Focus areas:

- chat list tab integration
- conversation filtering
- backend query support
- participation-based logic
- store/state updates
- correct tab placement in the UI
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Claude Opus 4.6 (with Figma MCP)
&lt;/h3&gt;

&lt;p&gt;Here's the response from Claude Opus 4.6:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/2AsxkzNUZdI"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;You can find the code it generated here: &lt;a href="https://gist.github.com/shricodev/314fa9f6d90859d6146d49d040eecf4e" rel="noopener noreferrer"&gt;Opus 4.6 source Code&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Opus nailed this one.&lt;/p&gt;

&lt;p&gt;This is exactly the kind of test where you are not making massive frontend changes, but instead need a solid understanding of how the app actually works so the feature fits naturally into the existing codebase.&lt;/p&gt;

&lt;p&gt;The only issue I noticed was some UI flickering. But that looked more like a minor issue than anything wrong with the feature implementation itself.&lt;/p&gt;

&lt;p&gt;Other than that, it worked perfectly.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Output token Cost:&lt;/strong&gt; ~$0.02&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duration:&lt;/strong&gt; 12 minutes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Output Token Usage:&lt;/strong&gt; ~1.3K&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code Changes:&lt;/strong&gt; 13 files changed, 52 insertions(+), 11 deletions(-)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Kombai
&lt;/h3&gt;

&lt;p&gt;Here's the response from Kombai:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/Pmpd3YvHrJk"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;You can find the code it generated here: &lt;a href="https://gist.github.com/shricodev/5f52c2ae2274a734d0eed68fccfffc15" rel="noopener noreferrer"&gt;Kombai source Code&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There was no flickering here, and Kombai also placed everything where it actually made sense. Claude got the logic right too, but the tab placement felt a bit off. Kombai just tied it together better, and the whole thing felt a bit cleaner.&lt;/p&gt;

&lt;p&gt;So overall, both did well here, but Kombai’s result felt cleaner and better integrated.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Code Changes:&lt;/strong&gt; 7 files changed, 30 insertions(+), 9 deletions(-)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duration:&lt;/strong&gt; ~8 minutes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fif5cvarx091xh9emzb0l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fif5cvarx091xh9emzb0l.png" alt="Kombai task completion" width="800" height="432"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Final Verdict
&lt;/h2&gt;

&lt;p&gt;So, what’s the takeaway?&lt;/p&gt;

&lt;p&gt;After testing both on real tasks in real codebases, Kombai was clearly stronger for frontend work.&lt;/p&gt;

&lt;p&gt;That is not to say Claude Code with Opus 4.6 is bad. Far from it.&lt;/p&gt;

&lt;p&gt;It is one of the strongest coding models available right now, and it can do some serious work. In some cases, especially the Chatwoot test, it understood the codebase really well and shipped something that was genuinely solid.&lt;/p&gt;

&lt;p&gt;Claude Opus lineup is too good for general coding.&lt;/p&gt;

&lt;p&gt;Kombai is a frontend specialized tool.&lt;/p&gt;

&lt;p&gt;And for frontend-heavy work, that specialization really shows.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fym68r25yz6fowfyulu7y.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fym68r25yz6fowfyulu7y.gif" alt="too good gif" width="400" height="315"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That said, I do not think you should take my word for it blindly.&lt;/p&gt;

&lt;p&gt;Also, this does not mean Kombai will win every project or every workflow.&lt;/p&gt;

&lt;p&gt;If you are doing backend-heavy work, infra changes, full-stack architecture, or mostly CLI-based development, Claude Code still makes a ton of sense.&lt;/p&gt;

&lt;p&gt;The design side is also getting more interesting now. I did not fully test Design Mode in this post, because that deserves a separate comparison.&lt;/p&gt;

&lt;p&gt;Honestly, the best way to judge tools like this is to try them yourself. A comparison like this can give you a rough idea, but it really clicks only when you use them on your own codebase and see how they actually feel.&lt;/p&gt;

&lt;p&gt;That’s all for this one. Thank you for reading! ✌️&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Claude Code vs. OpenCode without the hype</title>
      <dc:creator>Shrijal Acharya</dc:creator>
      <pubDate>Thu, 21 May 2026 13:55:18 +0000</pubDate>
      <link>https://dev.to/composiodev/claude-code-vs-opencode-without-the-hype-j1f</link>
      <guid>https://dev.to/composiodev/claude-code-vs-opencode-without-the-hype-j1f</guid>
      <description>&lt;p&gt;Everyone wants a coding agent now.&lt;/p&gt;

&lt;p&gt;Not a chatbot that explains code.&lt;/p&gt;

&lt;p&gt;An actual agent that can read your repo, edit files, run commands, use tools, and keep moving while you supervise.&lt;/p&gt;

&lt;p&gt;Claude Code and OpenCode are two of the most interesting takes on that idea.&lt;/p&gt;

&lt;p&gt;Claude Code is the polished Anthropic-native route.&lt;/p&gt;

&lt;p&gt;OpenCode is the open-source route for people who want more model choice, more control, and a setup they can tweak.&lt;/p&gt;

&lt;p&gt;And that difference matters more than it looks.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5bcj3t5mypqia64m30wr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5bcj3t5mypqia64m30wr.png" alt="distracted man GIF" width="687" height="361"&gt;&lt;/a&gt;&lt;/p&gt;




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

&lt;blockquote&gt;
&lt;p&gt;ℹ️ Open-source coding agent with model and tool control&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2k02x3jygu2mwj40jz44.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2k02x3jygu2mwj40jz44.png" alt="OpenCode" width="799" height="410"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;OpenCode is an open-source coding agent for developers who want more control over their AI coding setup.&lt;/p&gt;

&lt;p&gt;It runs in the terminal, IDE, and desktop, and lets you bring your own model instead of &lt;strong&gt;locking you into one provider&lt;/strong&gt;. Claude, GPT, Gemini, local models, and 75+ other providers are supported. That is probably the biggest reason people care about it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkem0kiznc0fm4g9gyvw9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkem0kiznc0fm4g9gyvw9.png" alt="OpenCode tweet" width="799" height="439"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It also comes with the things you expect from a serious coding agent now: LSP support, multi-session workflows, project memory through &lt;code&gt;AGENTS.md&lt;/code&gt;, MCP tools, custom agents, plugins, and editor support, and maybe a bunch more.&lt;/p&gt;

&lt;p&gt;So the pitch is not just “AI in your terminal.”&lt;/p&gt;

&lt;p&gt;That undersells it.&lt;/p&gt;

&lt;p&gt;OpenCode is closer to a &lt;strong&gt;coding-agent workbench&lt;/strong&gt;. You bring the model, the provider, the editor, the agents, and the workflow. OpenCode gives you the open layer that ties it all together.&lt;/p&gt;

&lt;p&gt;Not everyone needs that level of control.&lt;/p&gt;

&lt;p&gt;But some developers absolutely do.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💁 OpenCode is for developers who want to tweak every single detail of their coding agent.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is what makes it interesting next to Claude Code.&lt;/p&gt;




&lt;h2&gt;
  
  
  What is Claude Code
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;ℹ️ Anthropic’s polished coding agent for your terminal.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fivw9mx75nhkdloyczjr5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fivw9mx75nhkdloyczjr5.png" alt="Claude Code" width="800" height="208"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Claude Code is Anthropic’s coding agent that lives in your terminal.&lt;/p&gt;

&lt;p&gt;The idea is pretty same here, it can read your codebase, edit files, run commands, handle Git stuff, and all through prompts.&lt;/p&gt;

&lt;p&gt;The big difference is that Claude Code is built around Claude.&lt;/p&gt;

&lt;p&gt;That sounds obvious, but it matters.&lt;/p&gt;

&lt;p&gt;You are not coming here to mix and match ten different model providers. You are coming here because you trust Anthropic’s models, and you want the cleanest experience around them.&lt;/p&gt;

&lt;p&gt;Claude Code also comes with a lot of serious agent features: project memory through &lt;code&gt;CLAUDE.md&lt;/code&gt;, slash commands, permissions, hooks, MCP, plugins, custom subagents, and IDE integrations.&lt;/p&gt;

&lt;p&gt;Claude Code is closer to a Claude-native coding environment. The model, the agent loop, the tool use, the permissions, and the workflow all come from the same Anthropic-shaped box.&lt;/p&gt;

&lt;p&gt;Less DIY.&lt;/p&gt;

&lt;p&gt;But there is also a small shift happening.&lt;/p&gt;

&lt;p&gt;Some developers are starting to move from Claude Code to OpenCode or OpenAI’s Codex for one simple reason: &lt;strong&gt;usage limits&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi4uxa5o6uxb1s9x87cgw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi4uxa5o6uxb1s9x87cgw.png" alt="Claude Code Usage Limit meme" width="800" height="830"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Claude Code is great, but when you are deep in a coding session, hitting limits feels brutal. And for heavier users, even the &lt;strong&gt;$200 Claude Max plan&lt;/strong&gt; does not always feel like enough.&lt;/p&gt;

&lt;p&gt;That is why OpenCode and Codex are tempting. Also read: &lt;a href="https://composio.dev/content/claude-code-vs-openai-codex" rel="noopener noreferrer"&gt;Claude Code vs. Codex: Detailed breakdown&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When Claude hits the wall, people still need a way to keep shipping.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💁 If you're an Anthropic fanboy, and don't care about other models, stick to Claude Code.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  High Level Architecture
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;ℹ️ How both the agents work&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;At a high level, both OpenCode and Claude Code follow the same basic agent loop.&lt;/p&gt;

&lt;p&gt;You give it a task.&lt;/p&gt;

&lt;p&gt;It looks at the repo.&lt;/p&gt;

&lt;p&gt;It decides what files, commands, or tools it needs.&lt;/p&gt;

&lt;p&gt;It takes an action.&lt;/p&gt;

&lt;p&gt;Then it reads the result and keeps going.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcv9tejzbqvy4whr7jcj2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcv9tejzbqvy4whr7jcj2.png" alt="Coding Agent architecture" width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;ℹ️ This is the highest-level architecture of a coding agent. A few details change from tool to tool.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That loop is the boring part.&lt;/p&gt;

&lt;p&gt;The interesting part is everything around it.&lt;/p&gt;

&lt;p&gt;Here is a tiny example of that loop in practice.&lt;/p&gt;

&lt;p&gt;I gave Claude Code and OpenCode the same small task in a demo word-count repo:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Add a &lt;code&gt;--json&lt;/code&gt; flag to a word-count CLI, update the tests, and run them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The interesting part is not the feature. It is watching both agents go through the same shape: understand the repo, plan the change, edit the files, and run the tests.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/D74fsmbwE98"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Claude Code wraps that loop in Anthropic’s own product system. You get Claude, project memory through &lt;code&gt;CLAUDE.md&lt;/code&gt;, permissions, hooks, MCP, &lt;a href="https://composio.dev/content/top-claude-code-plugins" rel="noopener noreferrer"&gt;plugins&lt;/a&gt;, &lt;a href="https://composio.dev/content/top-claude-skills" rel="noopener noreferrer"&gt;Claude skills&lt;/a&gt;, and subagents in one single setup.&lt;/p&gt;

&lt;p&gt;OpenCode takes a more open route. It gives you the agent runtime, but lets you bring different models, providers, agents, tools, and workflows. Its docs split agents into primary agents and subagents, and let you configure specialized assistants with custom prompts, models, and tool access.&lt;/p&gt;

&lt;p&gt;So architecturally, the difference is not that one is an agent and the other is not.&lt;/p&gt;

&lt;p&gt;They both are.&lt;/p&gt;

&lt;p&gt;The real difference is who controls the harness around the agent.&lt;/p&gt;

&lt;p&gt;Claude Code gives you Anthropic’s harness.&lt;/p&gt;

&lt;p&gt;OpenCode gives you a harness you can inspect, and configure.&lt;/p&gt;




&lt;h2&gt;
  
  
  Context, memory and tool use
&lt;/h2&gt;

&lt;p&gt;Both Claude Code and OpenCode are doing the same basic thing: they build a giant prompt, stuff it with repo context, tool definitions, memory files, recent messages, and tool results, then ask the model what to do next.&lt;/p&gt;

&lt;p&gt;The difference is how much of that system you control.&lt;/p&gt;

&lt;p&gt;Claude Code is more vertically integrated here. It is built around Anthropic models, so it can take advantage of Anthropic-specific stuff like prompt caching, native tool calls, and Claude’s own long-context behavior.&lt;/p&gt;

&lt;p&gt;That matters.&lt;/p&gt;

&lt;p&gt;Tool definitions, system prompts, and &lt;code&gt;CLAUDE.md&lt;/code&gt; can be cached between turns, which makes long coding sessions cheaper and faster than they would be if Claude had to re-read everything from scratch every single time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fphuqzyrqslhkmlnh7ah3.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fphuqzyrqslhkmlnh7ah3.jpg" alt="compaction in a coding agent" width="800" height="407"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;OpenCode takes a different route.&lt;/p&gt;

&lt;p&gt;It does not assume one model or one provider. Instead, it &lt;strong&gt;reads the model’s context limit&lt;/strong&gt; from the provider metadata and builds the session around that. So the same OpenCode setup can run with Claude, GPT, Gemini, Qwen, local models, or whatever else you plug in.&lt;/p&gt;

&lt;p&gt;That flexibility is the whole point.&lt;/p&gt;

&lt;p&gt;But it also means OpenCode has to normalize all the weird provider differences: tool call IDs, cache support, model limits, and tool-calling parts.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F75v9hwsvotu1qmz1k8p0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F75v9hwsvotu1qmz1k8p0.png" alt="OpenCode support for multiple providers" width="799" height="255"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Claude Code gets to optimize deeply for Claude.&lt;/p&gt;

&lt;p&gt;OpenCode has to work with everyone.&lt;/p&gt;

&lt;p&gt;Memory works the same way.&lt;/p&gt;

&lt;p&gt;Claude Code uses &lt;code&gt;CLAUDE.md&lt;/code&gt; as the main project memory file. It can also load nested &lt;code&gt;CLAUDE.md&lt;/code&gt; files, user-level memory, and auto-memory. So it feels more like the agent has a built-in memory system.&lt;/p&gt;

&lt;p&gt;OpenCode uses &lt;code&gt;AGENTS.md&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is more portable. You can commit it to the repo, share it with the team, and use it as a general agent instruction file instead of something tied to one vendor. OpenCode can even fall back to &lt;code&gt;CLAUDE.md&lt;/code&gt;, which makes migration easier.&lt;/p&gt;

&lt;p&gt;At some point, every agent runs out of context.&lt;/p&gt;

&lt;p&gt;Claude Code handles this by compacting the conversation. Older tool outputs are cleared first, then the session gets summarized if needed. That is why Claude Code has commands like &lt;code&gt;/context&lt;/code&gt; and &lt;code&gt;/compact&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;OpenCode is a bit more explicit. It checks whether the session is close to the model’s context limit, keeps a buffer for output, and then prunes old tool outputs before doing a full summary. The important bit is that OpenCode stores the raw history in &lt;strong&gt;SQLite&lt;/strong&gt;, so pruning does not mean the data is gone forever.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7om4vrfngpmqdjsh3phe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7om4vrfngpmqdjsh3phe.png" alt="OpenCode flexibility" width="800" height="351"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Tool use follows the same pattern.&lt;/p&gt;

&lt;p&gt;Claude Code gives you a polished default toolbelt: read, write, edit, grep, glob, bash, web fetch, todo tracking, MCP, hooks, skills, and subagents.&lt;/p&gt;

&lt;p&gt;OpenCode gives you a smaller but more configurable tool system: read, write, edit, patch, bash, grep, glob, web fetch, task, todo, &lt;a href="https://composio.dev/content/10-best-opencode-skills-that-are-actually-useful-in-2026" rel="noopener noreferrer"&gt;skills&lt;/a&gt;, MCP, custom tools, and experimental LSP support.&lt;/p&gt;

&lt;p&gt;The difference is who controls the tool layer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Subagents and task delegation
&lt;/h2&gt;

&lt;p&gt;Subagents are basically how coding agents avoid stuffing everything into one giant conversation.&lt;/p&gt;

&lt;p&gt;Instead of making the main agent do every task itself, it can delegate a smaller job to another agent with its own context window, prompt, tools, and permissions.&lt;/p&gt;

&lt;p&gt;Claude Code and OpenCode both follow the same basic pattern here.&lt;/p&gt;

&lt;p&gt;The parent agent calls a &lt;code&gt;Task&lt;/code&gt; or &lt;code&gt;task&lt;/code&gt; tool.&lt;/p&gt;

&lt;p&gt;A child agent spins up.&lt;/p&gt;

&lt;p&gt;It does the work in isolation.&lt;/p&gt;

&lt;p&gt;Then it returns one final message back to the parent.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Main Agent
  |
  | calls Task / task
  v
Subagent
  - own context window
  - own prompt
  - own tools
  - own permissions
  |
  | returns final result only
  v
Main Agent continues...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That part is important. The parent usually does not see the full subagent conversation. It gets the result, not the whole reasoning.&lt;/p&gt;

&lt;p&gt;Claude Code has the more polished version of this.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmgfuv1jw1x2rt22d0i57.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmgfuv1jw1x2rt22d0i57.png" alt="Claude Code approach to subagents" width="799" height="269"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It ships with built-in agents like &lt;code&gt;Explore&lt;/code&gt;, &lt;code&gt;Plan&lt;/code&gt;, and &lt;code&gt;general-purpose&lt;/code&gt;. &lt;code&gt;Explore&lt;/code&gt; is mostly read-only and useful for repo research. &lt;code&gt;Plan&lt;/code&gt; helps gather context during planning. &lt;code&gt;general-purpose&lt;/code&gt; is for broader work.&lt;/p&gt;

&lt;p&gt;You can also define custom agents in &lt;code&gt;.claude/agents/&lt;/code&gt; with YAML frontmatter for things like &lt;code&gt;tools&lt;/code&gt;, &lt;code&gt;model&lt;/code&gt;, &lt;code&gt;permissionMode&lt;/code&gt;, &lt;code&gt;maxTurns&lt;/code&gt;, &lt;code&gt;skills&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That means you can do stuff like:&lt;/p&gt;

&lt;p&gt;Use a fast Haiku-style agent for repo search.&lt;/p&gt;

&lt;p&gt;Use a stronger model for code review.&lt;/p&gt;

&lt;p&gt;OpenCode has a similar shape, but it is more transparent.&lt;/p&gt;

&lt;p&gt;It has primary agents and subagents. Primary agents handle the main chat, while subagents are called through the &lt;code&gt;task&lt;/code&gt; tool or &lt;code&gt;@&lt;/code&gt; mentions.&lt;/p&gt;

&lt;p&gt;Custom agents can live in &lt;code&gt;.opencode/agents/*.md&lt;/code&gt; or inside &lt;code&gt;opencode.json&lt;/code&gt;, with fields like &lt;code&gt;mode&lt;/code&gt;, &lt;code&gt;model&lt;/code&gt;, &lt;code&gt;temperature&lt;/code&gt;, &lt;code&gt;steps&lt;/code&gt;, &lt;code&gt;prompt&lt;/code&gt;, and &lt;code&gt;permission&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The interesting part is that OpenCode stores subagents as real child sessions in &lt;strong&gt;SQLite&lt;/strong&gt;. So delegation is not just a hidden prompt trick. It is represented in the session model with its own messages, permissions, and snapshots.&lt;/p&gt;

&lt;p&gt;That fits OpenCode’s whole philosophy.&lt;/p&gt;

&lt;p&gt;Claude Code gives you a cleaner subagent experience.&lt;/p&gt;

&lt;p&gt;OpenCode gives you a more inspectable one.&lt;/p&gt;


&lt;h2&gt;
  
  
  Permissions, safety, and control
&lt;/h2&gt;

&lt;p&gt;This is where the two are very different.&lt;/p&gt;

&lt;p&gt;Claude Code is more conservative by default. It has permission modes, allow/ask/deny rules, hooks, and sandboxing around &lt;strong&gt;Bash&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So you can allow boring commands like tests, deny obvious footguns like .env reads or &lt;code&gt;curl | sh&lt;/code&gt;, and ask before anything risky.&lt;/p&gt;

&lt;p&gt;The important part is that Claude Code has multiple safety layers.&lt;/p&gt;

&lt;p&gt;Permissions decide what Claude is allowed to do.&lt;/p&gt;

&lt;p&gt;Something like:&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;"permissions"&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;"allow"&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;"Bash(npm run test *)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash(git status *)"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"deny"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Read(./.env)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Read(./secrets/**)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash(curl *)"&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;Hooks can intercept tool calls before or after they run and sandboxing gives Bash an OS-level boundary.&lt;/p&gt;

&lt;p&gt;OpenCode is simpler.&lt;/p&gt;

&lt;p&gt;Most of the control lives in one permission object inside &lt;code&gt;opencode.json&lt;/code&gt;. You can set rules for &lt;code&gt;bash&lt;/code&gt;, &lt;code&gt;edit&lt;/code&gt;, &lt;code&gt;read&lt;/code&gt;, &lt;code&gt;task&lt;/code&gt;, &lt;code&gt;webfetch&lt;/code&gt;, and other tools from the same place.&lt;/p&gt;

&lt;p&gt;That is clean, but OpenCode is also more permissive by default. You are expected to configure the rules yourself.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe8i1czi8c1lgv8ujg9l8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe8i1czi8c1lgv8ujg9l8.png" alt="OpenCode permissions" width="800" height="352"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Something like:&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;"permission"&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;"*"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ask"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"bash"&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;"*"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ask"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"git status *"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"git push *"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deny"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"rm *"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deny"&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;"edit"&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;"*"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deny"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"packages/web/src/**/*.tsx"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ask"&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;It does have some smart checks, especially for Bash. OpenCode parses shell commands with tree-sitter (the same thing you have inside NeoVim), so it can detect risky commands like &lt;code&gt;rm&lt;/code&gt;, &lt;code&gt;mv&lt;/code&gt;, &lt;code&gt;chmod&lt;/code&gt;, or paths outside the project more carefully than plain string matching.&lt;/p&gt;

&lt;p&gt;But there is no &lt;a href="https://www.anthropic.com/engineering/claude-code-sandboxing" rel="noopener noreferrer"&gt;native sandbox&lt;/a&gt; like Claude Code.&lt;/p&gt;

&lt;p&gt;The bigger OpenCode power feature is plugins. Plugins can intercept tool execution, add custom tools, and change agent behavior.&lt;/p&gt;

&lt;p&gt;That makes OpenCode way more hackable.&lt;/p&gt;


&lt;h2&gt;
  
  
  What the Claude Code leak tells us
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7h7v5xmi6q45a33xjk2v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7h7v5xmi6q45a33xjk2v.png" alt="Claude Code leak" width="799" height="337"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The interesting part of the leak is what it showed about coding agents.&lt;/p&gt;

&lt;p&gt;A lot of Claude magic is in the harness around the model: context management, tool descriptions, prompt caching, permissions, compaction, subagents, and the agent loop.&lt;/p&gt;

&lt;p&gt;OpenCode does pretty much the same. It is not trying to clone some impossible model-level feature. It is trying to build a different harness around similar idea.&lt;/p&gt;

&lt;p&gt;OpenCode’s advantage is that the harness is open, inspectable, and replaceable.&lt;/p&gt;

&lt;p&gt;Another thing that's clear is that the future is not just about better models, but the system around them.&lt;/p&gt;

&lt;p&gt;Better context control.&lt;/p&gt;

&lt;p&gt;Better tool boundaries.&lt;/p&gt;

&lt;p&gt;Better memory.&lt;/p&gt;

&lt;p&gt;Better permissions.&lt;/p&gt;

&lt;p&gt;That is why this comparison is even interesting. Claude Code and OpenCode are not just two CLIs. They are two different answers to the same question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;❓ How much of the agent stack should be final, and how much should developers be able to control?&lt;/p&gt;
&lt;/blockquote&gt;


&lt;h2&gt;
  
  
  So, which should you pick?
&lt;/h2&gt;

&lt;p&gt;There is no clever answer here.&lt;/p&gt;

&lt;p&gt;Pick &lt;strong&gt;Claude Code&lt;/strong&gt; if you want the cleanest Claude-native coding agent experience.&lt;/p&gt;

&lt;p&gt;Pick &lt;strong&gt;OpenCode&lt;/strong&gt; if you want more control.&lt;/p&gt;

&lt;p&gt;Personally, I still love Claude Code.&lt;/p&gt;

&lt;p&gt;I really do.&lt;/p&gt;

&lt;p&gt;Anthropic models are banger, especially for coding. The problem is that the limits have started to piss me off. When you are deep in a coding session and the limit hits, it completely breaks the flow.&lt;/p&gt;

&lt;p&gt;But there is some relief now.&lt;/p&gt;

&lt;p&gt;On May 6, Anthropic announced a new compute partnership with &lt;strong&gt;SpaceX&lt;/strong&gt; and doubled Claude Code’s 5-hour limits for &lt;strong&gt;Pro&lt;/strong&gt;, &lt;strong&gt;Max&lt;/strong&gt;, &lt;strong&gt;Team&lt;/strong&gt;, and &lt;strong&gt;seat-based Enterprise users&lt;/strong&gt;. They also removed peak-time limits for Pro and Max users.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqosm66pi1fq6pu4k76t0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqosm66pi1fq6pu4k76t0.png" alt="Claude Code increase in usage limit" width="799" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That makes Claude Code a lot easier to recommend again.&lt;/p&gt;

&lt;p&gt;I am personally still mostly stuck with Claude Code because the experience is just that good.&lt;/p&gt;

&lt;p&gt;But I use OpenCode when I want to try newer models like Kimi, OpenAI models, or local models. That is where OpenCode makes more sense to me. And by no means, it is to say that you can't use Anthropic models in OpenCode, you can, and that makes it even better.&lt;/p&gt;


&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;Claude Code and OpenCode are both useful, but for different reasons.&lt;/p&gt;

&lt;p&gt;Claude Code is the one I’d pick if I just want the agent to work without thinking too much about setup. It feels cleaner, and better for getting into a repo quickly.&lt;/p&gt;

&lt;p&gt;OpenCode is more for when you want control. Different models, different providers, more ways to shape the workflow around how you actually code.&lt;/p&gt;

&lt;p&gt;I wouldn’t overthink it.&lt;/p&gt;

&lt;p&gt;If you hate setup and love Anthropic, use Claude Code.&lt;/p&gt;

&lt;p&gt;If you want more flexibility and less vendor lock-in, use OpenCode.&lt;/p&gt;

&lt;p&gt;That’s really the whole comparison.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4d09dor9q6cwva8zlg5o.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4d09dor9q6cwva8zlg5o.gif" alt="steve jobs meme" width="422" height="237"&gt;&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag__user ltag__user__id__1127015"&gt;
    &lt;a href="/shricodev" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1127015%2F1c5e48a2-f602-4e7d-8312-3c0322d155c6.jpg" alt="shricodev image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/shricodev"&gt;Shrijal Acharya&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/shricodev"&gt;SDE • GOLD @Microsoft Student Ambassador • Prev Lead Collab and Dev-Team Lead @oppiaorg • Mail for collaboration&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;



</description>
      <category>ai</category>
      <category>agents</category>
      <category>claude</category>
      <category>cli</category>
    </item>
    <item>
      <title>Kimi K2.6 vs. Claude Opus 4.7 in a Weird Game Coding Test ✅</title>
      <dc:creator>Shrijal Acharya</dc:creator>
      <pubDate>Tue, 05 May 2026 13:04:03 +0000</pubDate>
      <link>https://dev.to/composiodev/kimi-k26-vs-claude-opus-47-in-a-weird-game-coding-test-2ck3</link>
      <guid>https://dev.to/composiodev/kimi-k26-vs-claude-opus-47-in-a-weird-game-coding-test-2ck3</guid>
      <description>&lt;p&gt;Kimi K2.6 has been getting a lot of love lately, especially from devs who want a strong coding model without paying premium model prices every time they run a big prompt.&lt;/p&gt;

&lt;p&gt;So I wanted to see how good this model actually is. But this time, I wanted to compare it with something much heavier, the developers darling &lt;strong&gt;Claude Opus 4.7&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;On paper, Claude Opus 4.7 and Kimi K2.6 are very different models.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9me1tc37trrs3wur8p6o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9me1tc37trrs3wur8p6o.png" alt="Kimi K2.6 and Opus 4.7 benchmarks" width="800" height="422"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One is a premium frontier model from Anthropic. The other is Moonshot AI's much cheaper open model for coding and agentic tasks.&lt;/p&gt;

&lt;p&gt;The pricing difference is pretty wild too. Claude Opus 4.7 costs $5/M input tokens and $25/M output tokens. Kimi K2.6 is listed at $0.95/M input tokens and $4/M output tokens, with cached input going even lower at $0.16/M tokens.&lt;/p&gt;

&lt;p&gt;That is a pretty big gap.&lt;/p&gt;

&lt;p&gt;So in this article, we'll see how the cheaper model, Kimi K2.6, does against Claude Opus 4.7.&lt;/p&gt;

&lt;p&gt;For the test, I gave both models the same coding task: build a small Minetest (similar to Minecraft) bounty board with a TypeScript backend, then extend it with Google Sheets logging through Composio.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4zpwug27ph8tc9d8dti6.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4zpwug27ph8tc9d8dti6.gif" alt="Angrybird interacting with a telescope" width="480" height="260"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;If you want the quick take, Claude Opus 4.7 clearly won this test, but it was painfully expensive.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Opus was better at the real task.&lt;/strong&gt; The local build was cleaner, and it was the only one that got the real Google Sheets integration working.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kimi did pretty well in Test 1.&lt;/strong&gt; It got the local bounty board working for way less money, but it needed more debugging.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test 2 changed the whole comparison.&lt;/strong&gt; Opus was expensive, but it finished. Kimi just could not put it all together.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cost difference was wild though.&lt;/p&gt;

&lt;p&gt;For the first local bounty board test, Opus cost around &lt;strong&gt;$3.59&lt;/strong&gt;, while Kimi came in at around &lt;strong&gt;$0.39&lt;/strong&gt;. That is a huge gap. For the basic version, Kimi honestly did pretty well for the price.&lt;/p&gt;

&lt;p&gt;But once the task got a little more real, the gap became way more obvious.&lt;/p&gt;

&lt;p&gt;Opus got it working, even though it needed a little back and forth. The Google Sheets sync worked, and the project was modular enough that I could test the whole flow with two &lt;code&gt;curl&lt;/code&gt; requests without even opening the game.&lt;/p&gt;

&lt;p&gt;The painful part is that the Composio run alone cost &lt;strong&gt;$16&lt;/strong&gt; and took around &lt;strong&gt;28min 52sec API time&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Kimi, on the other hand, burned &lt;strong&gt;135k+ tokens&lt;/strong&gt;, took around &lt;strong&gt;25 minutes&lt;/strong&gt;, cost around &lt;strong&gt;$5.03&lt;/strong&gt;, and still did not really get any closer.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;👀 So yeah, Kimi K2.6 is a usable and interesting cheaper model. But in this test, it could not really come close to Opus 4.7 for real-world coding.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Evaluation
&lt;/h2&gt;

&lt;p&gt;I treated this like a real project, not a benchmark chart. Both models got the same prompts, and I compared the results based on whether it actually worked, how clean the code was, how much debugging it needed, how long it took, and how much it cost. That last one matters a lot here.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Same tasks and prompts&lt;/strong&gt; for both models (Test 1: local-only bounty board, Test 2: real Composio Google Sheets sync).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same target architecture&lt;/strong&gt;: Minetest/Luanti Lua mod + TypeScript backend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same success criteria&lt;/strong&gt;: &lt;code&gt;/bounty&lt;/code&gt; flow works in-game, backend APIs behave correctly, and in Test 2 the completion is appended to Google Sheets via Composio.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What I measured
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Functional correctness (most important):&lt;/strong&gt; Did it work end-to-end with real verification?

&lt;ul&gt;
&lt;li&gt;Local run: could a player generate, progress, and complete bounties without breaking state?&lt;/li&gt;
&lt;li&gt;Backend: did &lt;code&gt;/health&lt;/code&gt;, &lt;code&gt;/api/bounty/generate&lt;/code&gt;, &lt;code&gt;/api/bounty/complete&lt;/code&gt;, and &lt;code&gt;/api/leaderboard&lt;/code&gt; return the expected shapes?&lt;/li&gt;
&lt;li&gt;Test 2: did the Google Sheets append succeed, and could I validate it from the API without needing to be in the game?&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;Code quality and structure:&lt;/strong&gt; modularity, clarity, and whether the repo was easy to reason about and test.&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;Debug burden:&lt;/strong&gt; how many follow-ups were needed, how confusing the failure modes were, and whether issues were “real bugs” vs. “misconfiguration traps.”&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;Time:&lt;/strong&gt; API time and wall time for each run.&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;Cost and token usage:&lt;/strong&gt; input, output, cache behavior, and total run cost.&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;Practical ergonomics:&lt;/strong&gt; whether I could validate quickly (for example, testing the full backend + Composio flow with &lt;code&gt;curl&lt;/code&gt;).&lt;/li&gt;

&lt;/ul&gt;

&lt;h3&gt;
  
  
  How I verified outcomes
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Test 1:&lt;/strong&gt; ran the backend locally, joined a local Minetest world, used &lt;code&gt;/bounty&lt;/code&gt;, and confirmed task tracking, rewards, and leaderboard persistence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test 2:&lt;/strong&gt; verified the end-to-end sync by generating and completing a bounty via the backend API, and confirming a successful Google Sheets append through Composio.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Scoring approach
&lt;/h3&gt;

&lt;p&gt;This was not a “unit test leaderboard” benchmark. It was a real build-and-ship check.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A model “wins” when the project works with minimal intervention.&lt;/li&gt;
&lt;li&gt;A model “loses” when it cannot reach a working state in reasonable time/cost, even if parts of the code look promising.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Coding Test
&lt;/h2&gt;

&lt;p&gt;For this test, I used the following CLI coding agents:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Claude Opus 4.7:&lt;/strong&gt; Claude Code, Anthropic's terminal-based agentic coding tool&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvc5bfm8a9ump0ebp5f7o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvc5bfm8a9ump0ebp5f7o.png" alt="Claude Opus 4.7 model in CC" width="800" height="159"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Kimi K2.6:&lt;/strong&gt; OpenCode via OpenRouter&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxbg1utbhx6wenvgpszln.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxbg1utbhx6wenvgpszln.png" alt="Kimi K2.6 in OpenRouter" width="800" height="270"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;ℹ️ This is a practical coding test, so both models get the same prompt. I will compare time taken, code quality, token usage, cost, and all that stuff.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  What are we building?
&lt;/h3&gt;

&lt;p&gt;For this test, I wanted something small enough to verify properly, but still weird enough to show how each model handles an unusual idea.&lt;/p&gt;

&lt;p&gt;So, we're building a simple Minetest/Luanti bounty board.&lt;/p&gt;

&lt;p&gt;A player can join a local world, run &lt;code&gt;/bounty&lt;/code&gt;, get a task like mining dirt or placing torches, and receive a reward after completing it.&lt;/p&gt;

&lt;p&gt;After that, the backend records the completion, logs it to Google Sheets through Composio.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I get it, the concept is a little unusual on purpose.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Test Prompts
&lt;/h3&gt;

&lt;p&gt;Both models received the same prompts for each test.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Test 1 Prompt:&lt;/strong&gt; &lt;a href="https://gist.github.com/shricodev/a9fa4512142e74c72afc0e8b6b121384" rel="noopener noreferrer"&gt;Local Bounty Board Prompt&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test 2 Prompt:&lt;/strong&gt; &lt;a href="https://gist.github.com/shricodev/5e8f4803f1a9af152403e246b7bcd617" rel="noopener noreferrer"&gt;Real Composio Integration Prompt&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Test 1: Local Bounty Board
&lt;/h3&gt;

&lt;p&gt;This first test is the basic version of the idea.&lt;/p&gt;

&lt;p&gt;No external tools, no Composio. Just the game, the backend, and the local bounty flow working properly.&lt;/p&gt;

&lt;p&gt;The goal was simple. A player runs &lt;code&gt;/bounty&lt;/code&gt;, gets a task, completes it inside the game, and the backend tracks the progress without everything falling apart.&lt;/p&gt;

&lt;h3&gt;
  
  
  Claude Opus 4.7
&lt;/h3&gt;

&lt;p&gt;Claude Opus 4.7 handled the first test really well.&lt;/p&gt;

&lt;p&gt;The local bounty board worked end to end. It built the TypeScript backend, the Minetest/Luanti Lua mod, the command flow, progress tracking, rewards, and leaderboard persistence without needing a bunch of follow-up fixes.&lt;/p&gt;

&lt;p&gt;The file structure also felt nice, which I had specifically asked for in the prompt:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1ldggdwrwcrk42jgswb0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1ldggdwrwcrk42jgswb0.png" alt="Claude Opus 4.7 created file structure" width="800" height="345"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The backend was built with Express, Zod, and Vitest. It also handled the boring stuff properly, which honestly matters a lot here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;npm test&lt;/code&gt; passed with &lt;strong&gt;11/11 tests&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;npm run build&lt;/code&gt; passed cleanly&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/health&lt;/code&gt;, &lt;code&gt;/api/bounty/generate&lt;/code&gt;, &lt;code&gt;/api/bounty/complete&lt;/code&gt;, and &lt;code&gt;/api/leaderboard&lt;/code&gt; returned the right response shapes&lt;/li&gt;
&lt;li&gt;incomplete bounty completions returned a clean &lt;code&gt;400&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It created the Lua files cleanly, used &lt;code&gt;minetest.request_http_api()&lt;/code&gt;, handled &lt;code&gt;secure.http_mods&lt;/code&gt;, tracked digging and placing, stored player bounty state, and handled inventory rewards properly.&lt;/p&gt;

&lt;p&gt;The whole run took around &lt;strong&gt;12 minutes of API time&lt;/strong&gt;, with about &lt;strong&gt;23 minutes wall time&lt;/strong&gt;. That is a bit longer than a quick web app build test, but for this kind of cross-stack project, it felt fair.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frwsc4lkcx6f9o43eujev.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frwsc4lkcx6f9o43eujev.png" alt="Claude Opus 4.7 time taken for the build" width="800" height="286"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The cost came out to &lt;strong&gt;$3.59&lt;/strong&gt;, which is definitely not cheap. But to be fair, the output was actually useful. It added a lot of code, but most of it was real implementation, not random filler like &lt;code&gt;CONTRIBUTING.md&lt;/code&gt;, &lt;code&gt;INSTALLATION.md&lt;/code&gt;, and all those extra files models sometimes create for no reason.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgj4v5gc4wfmy9bov64qq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgj4v5gc4wfmy9bov64qq.png" alt="Claude Opus 4.7 cost for the build" width="800" height="126"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can find the code it generated here: &lt;a href="https://github.com/shricodev/opus-kimi-minetest-game-mod/tree/04fe23a6f6eb363ef7cfb6af9e0e91993568a718" rel="noopener noreferrer"&gt;Claude Opus 4.7 Code&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's the demo:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/ZAB9fTdTV5Y"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost:&lt;/strong&gt; ~$3.59&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duration:&lt;/strong&gt; 12min 3sec API time, 23min 53sec wall time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code Changes:&lt;/strong&gt; +1,688 lines, 0 lines removed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token Usage:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Input:&lt;/strong&gt; 65&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Output:&lt;/strong&gt; 54.8k&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache read:&lt;/strong&gt; 2.8M&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache write:&lt;/strong&gt; 129.8k&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Quick Verdict&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It worked end to end without much tweaking. I only had to configure &lt;code&gt;~/.minetest/minetest.conf&lt;/code&gt; and add this line:&lt;/p&gt;


&lt;pre class="highlight plaintext"&gt;&lt;code&gt;secure.http_mods = bountyboard
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;Pretty much everything else was smooth. Great quick MVP.&lt;/p&gt;

&lt;p&gt;I noticed one small issue: &lt;code&gt;mine_node&lt;/code&gt; bounties can be farmed by placing and then re-mining the same blocks, because vanilla Minetest does not track who placed a node.&lt;/p&gt;

&lt;p&gt;But that's fine. That is not really a code issue here.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Kimi K2.6
&lt;/h3&gt;

&lt;p&gt;The core idea worked. Kimi created the TypeScript backend, the Minetest/Luanti mod, the bounty commands, task tracking, completion flow, rewards, and leaderboard logic.&lt;/p&gt;

&lt;p&gt;The backend side looked solid enough. It used Express, Zod, and Vitest, and the main routes were there:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/health&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/api/bounty/generate&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/api/bounty/complete&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/api/leaderboard&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It also created the Lua mod files properly and handled the basic &lt;code&gt;/bounty&lt;/code&gt; flow inside Minetest. The code was not bad either. I just felt like it was not as clean or modular as what Opus 4.7 wrote.&lt;/p&gt;

&lt;p&gt;But there was one really irritating issue.&lt;/p&gt;

&lt;p&gt;Somehow, Kimi wrote the global Minetest config in &lt;code&gt;~/.minetest/minetest.conf&lt;/code&gt; with this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;secure.http_mods&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;bountykimi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;But then it also created a world config and added a different mod name there.&lt;/p&gt;

&lt;p&gt;So when I loaded the world, Minetest used the world-level config. That basically overrode the global config behavior I was expecting. Because of that, the HTTP API was not enabled for the actual mod that was running.&lt;/p&gt;

&lt;p&gt;This took me more than half an hour to debug.&lt;/p&gt;

&lt;p&gt;And honestly, because I do not have much experience with Minetest config behavior, this was super annoying.&lt;/p&gt;

&lt;p&gt;The run itself was much cheaper and faster than Opus. Kimi used around &lt;strong&gt;52k context tokens&lt;/strong&gt;, took about &lt;strong&gt;9 minutes 27 seconds&lt;/strong&gt;, and cost around &lt;strong&gt;$0.39&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxi3s6ic25sy6620np9wf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxi3s6ic25sy6620np9wf.png" alt="Kimi K2.6 token usage and time for the build" width="800" height="424"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That price difference is pretty wild. Opus cost around $3.59 for the first test, while Kimi came in under $0.40.&lt;/p&gt;

&lt;p&gt;You can find the code it generated here: &lt;a href="https://github.com/shricodev/opus-kimi-minetest-game-mod/tree/5c9033f77f2dff7792578b3fa126f7343643b5d1" rel="noopener noreferrer"&gt;Kimi K2.6 Code&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's the demo:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/kpE4EbWdqu4"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost:&lt;/strong&gt; ~$0.39&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duration:&lt;/strong&gt; ~9min 27sec&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code Changes:&lt;/strong&gt; +4,671 lines, 0 lines removed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context Used:&lt;/strong&gt; 52,073 tokens&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context Window Used:&lt;/strong&gt; 20%&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Quick Verdict&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The local bounty board idea worked, the code was usable, and the model clearly understood the Lua + TypeScript setup. But if you notice, Kimi wrote more than twice as much code as Opus 4.7.&lt;/p&gt;

&lt;p&gt;The main problem was the Minetest config mess. It added &lt;code&gt;secure.http_mods = bountykimi&lt;/code&gt; globally, but then created another world-level config with a different mod name, which made debugging way more painful.&lt;/p&gt;

&lt;p&gt;So yeah, Kimi passed the first test, but not as smoothly as Opus.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  Test 2: Real Composio Integration
&lt;/h3&gt;

&lt;p&gt;Now this is where the actual test, and the fun, begins.&lt;/p&gt;

&lt;p&gt;The custom mod is ready, so now it is time to integrate Composio and give the game a quick agentic touch.&lt;/p&gt;

&lt;p&gt;The idea is simple. As players progress through the game, their bounty completions get logged into Google Sheets with Composio.&lt;/p&gt;
&lt;h3&gt;
  
  
  Claude Opus 4.7
&lt;/h3&gt;

&lt;p&gt;Claude Opus 4.7 did manage to add the real Composio integration, but this one was not as smooth as Test 1.&lt;/p&gt;

&lt;p&gt;The backend could sync bounty completions to Google Sheets. The nice thing is that I did not even need to open Minetest to test whether it was working. Because the project was structured cleanly, I could test the whole backend flow with just two &lt;code&gt;curl&lt;/code&gt; requests.&lt;/p&gt;

&lt;p&gt;First, generate a bounty:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:8787/api/bounty/generate &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"player":"singleplayer","availableTasks":["collect_item"]}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;tee&lt;/span&gt; /tmp/b.json | jq
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Then complete it:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:8787/api/bounty/complete &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-nc&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;--argjson&lt;/span&gt; b &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq .bounty /tmp/b.json&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;--arg&lt;/span&gt; ts &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%Y-%m-%dT%H:%M:%SZ&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="s1"&gt;'{player:"singleplayer", bounty:$b, progress:{current:$b.target.count, required:$b.target.count}, completedAt:$ts}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;And if everything is configured correctly, the second response looks like this:&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;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bounty completed."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"leaderboard"&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;"player"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"singleplayer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"points"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"completedBounties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="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;"sync"&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;"googleSheets"&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;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Google Sheets row appended."&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;This is one of the things I love about Opus the most. It usually creates a pretty modular setup. The game mod, backend logic, and external sync were separated well enough that I could test the Composio part directly from the API without needing to run around inside the game every time.&lt;/p&gt;

&lt;p&gt;It did run into a dev server issue where the &lt;code&gt;tsx&lt;/code&gt; command was parsing &lt;code&gt;watch&lt;/code&gt; incorrectly and treating it like the entry file.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3mdm9m29o5a7q6mq10oe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3mdm9m29o5a7q6mq10oe.png" alt="Claude Opus 4.7 build error" width="800" height="218"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After a bit of back and forth, it fixed the error. It eventually built a small runtime env loader and adjusted the config import so the backend could read the environment properly before the rest of the app booted.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe4e3gju1m8fore381kvx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe4e3gju1m8fore381kvx.png" alt="Claude Opus 4.7 build error fix" width="800" height="203"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After that, the build worked and the Google Sheets sync started working.&lt;/p&gt;

&lt;p&gt;But that cost was painful. It literally cost me around &lt;strong&gt;$16&lt;/strong&gt;. Like, actually :(. If you are not watching usage, this thing can make you broke real fast.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffe8rgfuafxakh1oucp9j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffe8rgfuafxakh1oucp9j.png" alt="Claude Opus 4.7 time and cost" width="800" height="94"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It took &lt;strong&gt;28min 52sec API time&lt;/strong&gt;, and about &lt;strong&gt;1hr 17min wall time&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Apart from that, the code did work. But it cost way more than I expected for one run.&lt;/p&gt;

&lt;p&gt;You can find the code it generated here: &lt;a href="https://github.com/shricodev/opus-kimi-minetest-game-mod/tree/0c9c0fa3641796fd97890530c4116e05912b04f4" rel="noopener noreferrer"&gt;Claude Opus 4.7 Code - Composio&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's the demo:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/HWUmlysy18Q"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost:&lt;/strong&gt; $16.03&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duration:&lt;/strong&gt; 28min 52sec API time, 1hr 17min 40sec wall time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code Changes:&lt;/strong&gt; +1,848 lines, 507 lines removed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token Usage:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Input:&lt;/strong&gt; 100.2k with Claude Haiku 4.5, plus Opus usage shown in the session&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Output:&lt;/strong&gt; 3.2k with Claude Haiku 4.5, 123.3k with Claude Opus 4.7&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache read:&lt;/strong&gt; 22.3M&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache write:&lt;/strong&gt; 269.3k&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Quick Verdict:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Claude Opus 4.7 got the real Composio integration working, especially the Google Sheets logging.&lt;/p&gt;

&lt;p&gt;How cool is that? You add a custom agentic feature inside a game. A literal public game.&lt;/p&gt;

&lt;p&gt;So yes, it worked. But $16 for this one run hurt.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  Kimi K2.6
&lt;/h3&gt;

&lt;p&gt;Kimi K2.6 did not do well on this test. It was pretty much busted.&lt;/p&gt;

&lt;p&gt;From the start, it ran into a bunch of errors. The dev server broke, tests were failing, and even after a little handholding, it only managed to fix part of the test situation.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj3viy07x6cen4f8a6lxh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj3viy07x6cen4f8a6lxh.png" alt="Kimi K2.6 error in the build" width="800" height="193"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It eventually got past some of those failures, but the bigger problem was the actual Composio implementation. It did not seem fully sure how to wire the integration cleanly into the existing backend.&lt;/p&gt;

&lt;p&gt;I had to stop and help again and again, but it still could not make meaningful progress with the build.&lt;/p&gt;

&lt;p&gt;After spending more than &lt;strong&gt;25 minutes&lt;/strong&gt; and burning over &lt;strong&gt;130k tokens&lt;/strong&gt;, there was still no real progress. At that point, I had to stop the run.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flpehtobp7tgmpolmyhr4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flpehtobp7tgmpolmyhr4.png" alt="Kimi K2.6 build error" width="800" height="121"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Why on earth is it reading a &lt;code&gt;version.txt&lt;/code&gt; file?&lt;/p&gt;

&lt;p&gt;So yeah, I am calling this one a fail for Kimi K2.6.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost:&lt;/strong&gt; ~$5.03&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duration:&lt;/strong&gt; ~25min&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token Usage:&lt;/strong&gt; 135,109+&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Quick Verdict:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Kimi K2.6 failed this test.&lt;/p&gt;

&lt;p&gt;It got stuck around tests, build issues, and the real Composio implementation. Even with manual help, it could not get the integration into a clean working state.&lt;/p&gt;

&lt;p&gt;For the local bounty board, Kimi was surprisingly usable. But once the task moved into real external integration, it struggled a lot more.&lt;/p&gt;
&lt;/blockquote&gt;


&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Both Claude Opus 4.7 and Kimi K2.6 were pretty solid in this test, at least for the local version.&lt;/p&gt;

&lt;p&gt;The task was not that simple either. It had Lua, TypeScript, SDK docs, backend logic, game commands, and the full flow had to work end to end.&lt;/p&gt;

&lt;p&gt;Plus, the idea itself is not that common. Building an AI agent concept inside a custom game mod is not easy, and it is definitely not a one-shot thing, so props to both models.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fu9zolwkatuaoztum6991.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fu9zolwkatuaoztum6991.gif" alt="Clap" width="480" height="270"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Opus 4.7 did better overall. The code was cleaner, and as usual, Anthropic models are pretty good at that.&lt;/p&gt;

&lt;p&gt;The only thing I hate with Anthropic models is the session limit.&lt;/p&gt;

&lt;p&gt;I absolutely hate how little session usage you get. Opus 4.7 especially just eats through it completely in like 3 to 5 prompts.&lt;/p&gt;

&lt;p&gt;Kimi K2.6 is an interesting model. Open models have not always been the best in my experience with real-world projects, but with every new model, my expectations rise a little.&lt;/p&gt;

&lt;p&gt;Let's see where Kimi K2.6 goes from here.&lt;/p&gt;


&lt;div class="ltag__user ltag__user__id__1127015"&gt;
    &lt;a href="/shricodev" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1127015%2F1c5e48a2-f602-4e7d-8312-3c0322d155c6.jpg" alt="shricodev image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/shricodev"&gt;Shrijal Acharya&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/shricodev"&gt;SDE • GOLD @Microsoft Student Ambassador • Prev Lead Collab and Dev-Team Lead @oppiaorg • Mail for collaboration&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;



</description>
      <category>ai</category>
      <category>programming</category>
      <category>gamedev</category>
      <category>typescript</category>
    </item>
    <item>
      <title>My speaker broke, so I built a LAN speaker</title>
      <dc:creator>Shrijal Acharya</dc:creator>
      <pubDate>Tue, 28 Apr 2026 14:29:00 +0000</pubDate>
      <link>https://dev.to/shricodev/my-speaker-broke-so-i-built-a-lan-speaker-19nc</link>
      <guid>https://dev.to/shricodev/my-speaker-broke-so-i-built-a-lan-speaker-19nc</guid>
      <description>&lt;h2&gt;
  
  
  What's happening?
&lt;/h2&gt;

&lt;p&gt;This was around a year back when I started the project, after a small speaker that I had broke out of nowhere. Won't connect.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fd5hyoru6a4xegzrkukgq.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fd5hyoru6a4xegzrkukgq.jpeg" alt="Broken speaker" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I used it to listen to music every night before going to sleep (not sure if anybody else does the same, but it's one of my only fixed routines since childhood :D).&lt;/p&gt;




&lt;h2&gt;
  
  
  The idea
&lt;/h2&gt;

&lt;p&gt;Okay, so what's the idea?&lt;/p&gt;

&lt;p&gt;I had a thought. Why not build something resembling a speaker? Although I had never worked on a project that required working with audio and all that, I knew what I'd use to implement something like that, which was WebSocket (I'll explain why I chose it in a moment).&lt;/p&gt;

&lt;p&gt;And I did start on it.&lt;/p&gt;

&lt;p&gt;The irony is that I had just finished &lt;a href="https://go.dev/tour/" rel="noopener noreferrer"&gt;Tour of Go&lt;/a&gt;, &lt;a href="https://lets-go.alexedwards.net/" rel="noopener noreferrer"&gt;Let's Go&lt;/a&gt;, &lt;a href="https://lets-go-further.alexedwards.net/" rel="noopener noreferrer"&gt;Let's Go Further&lt;/a&gt;, and had just started on 100 Go Mistakes and How to Avoid Them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fadv8xgc17bm3oficmzps.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fadv8xgc17bm3oficmzps.png" alt="LinkedIn post on golang books" width="741" height="730"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So... I had to do this in Golang.&lt;/p&gt;

&lt;p&gt;I had quite a good idea of how to work with Golang, so it had to be done with it, also because I wanted to get away from the JavaScript ecosystem (the same Node.js, React, Next.js, yada yada was just too much).&lt;/p&gt;




&lt;h2&gt;
  
  
  Why WebSocket
&lt;/h2&gt;

&lt;p&gt;So, let's come to the plan. Why WebSocket?&lt;/p&gt;

&lt;p&gt;The main reason is that when I had the idea, it was WebSocket that came to my mind, and it's what I thought of before even starting to code the project.&lt;/p&gt;

&lt;p&gt;And WebSocket kind of makes sense as well. This was my thought process.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Lets you connect multiple connections from one device? &lt;strong&gt;Yes&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Is synchronous enough when streaming over multiple devices in LAN? &lt;strong&gt;Yes&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Something I've worked with a lot? &lt;strong&gt;Totally Yes&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's pretty much the reason I chose WebSocket. It might not be the best option, but it's what I had and something I was confident with when I started.&lt;/p&gt;

&lt;p&gt;One more thing I'll add here: since I'm streaming audio frames sequentially (PCM chunks, one after another), I actually need them to arrive in order. &lt;/p&gt;

&lt;p&gt;WebSocket runs over TCP, so that's handled for me. I don't have to think about it. That alone made it feel like the right call.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why not WebRTC or UDP?
&lt;/h3&gt;

&lt;p&gt;Let me tell you something pretty frankly: I never even knew there was something like WebRTC. I came across this term while building the project halfway through it.&lt;/p&gt;

&lt;p&gt;So, why not UDP?&lt;/p&gt;

&lt;p&gt;Okay, so UDP is connectionless, which means there's no guarantee that every packet arrives, and more importantly, no guarantee they arrive in order. For something like video calls or games, that's totally fine. You drop a frame, move on, nobody cares.&lt;/p&gt;

&lt;p&gt;But for audio? If a chunk goes missing or arrives out of order, you either get silence or a glitch.&lt;/p&gt;

&lt;p&gt;And since I'm streaming raw PCM (basically just a stream of bytes that represent sound), every single chunk matters. You can't just skip them.&lt;/p&gt;

&lt;p&gt;So UDP was out as well.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpllsruo4m6s36xvej2cs.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpllsruo4m6s36xvej2cs.gif" alt="Out GIF" width="480" height="270"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And WebRTC? Also, WebRTC is mostly built around browser-to-browser, peer-to-peer stuff. My setup is a server broadcasting to multiple clients over LAN, which isn't really what WebRTC is designed for. So even if I knew about it from day one, I'm not sure it would've been the right fit anyway.&lt;/p&gt;

&lt;p&gt;WebSocket was fine. It worked. And sometimes that's enough. I didn't want to overengineer.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;👀 I learned about all these new terms like &lt;strong&gt;PCM&lt;/strong&gt;, &lt;strong&gt;WebRTC&lt;/strong&gt;, and all that stuff during the build. So, I might say something wrong. I'm not really that familiar with them. So, just hit me in the comments if so.&lt;/p&gt;
&lt;/blockquote&gt;




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

&lt;p&gt;Okay, so the high level is pretty simple.&lt;/p&gt;

&lt;p&gt;There's a server and there are clients. The server is where the music is stored, and the clients are the devices that play it (could be the same device).&lt;/p&gt;

&lt;p&gt;Here's what actually happens when you press play:&lt;/p&gt;

&lt;p&gt;The server takes the MP3 file, decodes it into raw PCM (basically just bytes of sound data), and starts broadcasting those bytes over WebSocket to every connected client. No client-side decoding. The server does all of that.&lt;/p&gt;

&lt;p&gt;Here's a high level architecture:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Foi47bhzovugarytvtmyw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Foi47bhzovugarytvtmyw.png" alt="Gophercast architecture" width="800" height="402"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The tricky part is sync. If you just start streaming, each client will start playing at slightly different times, and it'll sound like a little echo.&lt;/p&gt;

&lt;p&gt;So what I did is, before playback starts, the server sends a timestamp to all clients saying, "Start playing at exactly this moment in time." Every client gets that timestamp, buffers the audio frames, and waits. When the clock hits that time, everyone starts together.&lt;/p&gt;

&lt;p&gt;It works because on a LAN with &lt;strong&gt;NTP&lt;/strong&gt;, all the device clocks are usually within a millisecond or two of each other. Close enough that you can't tell the difference (much).&lt;/p&gt;

&lt;p&gt;That's the whole thing, honestly.&lt;/p&gt;

&lt;p&gt;Server decodes -&amp;gt; broadcasts -&amp;gt; clients sync -&amp;gt; play&lt;/p&gt;




&lt;h2&gt;
  
  
  What I still don't know
&lt;/h2&gt;

&lt;p&gt;Okay, so during the build, I ran into something I didn't fully understand.&lt;/p&gt;

&lt;p&gt;Turns out your speaker doesn't play audio the exact moment you write data to it. There's some time it spends sitting in a system buffer before it actually comes out. And that delay varies by OS and audio system. On Linux, it's something around 50ms, I feel.&lt;/p&gt;

&lt;p&gt;Honestly, I had no idea there was something like this that you need to account for. This thing was completely debugged by &lt;strong&gt;GPT-5.4&lt;/strong&gt;. There's a hardcoded 50ms constant in the code that is counted when clients actually start playing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa7zha09z3aitxrfz1i05.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa7zha09z3aitxrfz1i05.gif" alt="I don't know gif" width="480" height="368"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There's also something in the code that keeps checking, roughly every second, whether the audio playing on your device is slightly ahead or behind.&lt;/p&gt;

&lt;p&gt;If it is, it quietly adds or removes a tiny bit of audio to bring it back in line. So small you'd never hear it.&lt;/p&gt;

&lt;p&gt;Both of these kind of work. I tested it. I just couldn't tell you exactly why the numbers are what they are.&lt;/p&gt;




&lt;h2&gt;
  
  
  How I usually run it
&lt;/h2&gt;

&lt;p&gt;Since my plan is to use my whole laptop as a "speaker", I usually have the server and client on the same system (my personal laptop).&lt;/p&gt;

&lt;p&gt;And I usually have it like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start the server:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gophercast serve &lt;span class="c"&gt;# now follow the TUI setup....&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ol&gt;
&lt;li&gt;Connect clients (each in a tmux split). Usually, I have around 2 clients when running on the same machine. More than 2 kind of distorts the audio quality.
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gophercast play &lt;span class="nt"&gt;--host&lt;/span&gt; &amp;lt;ip_from_serve&amp;gt; &lt;span class="nt"&gt;--port&lt;/span&gt; 8080 &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"client 1"&lt;/span&gt;
gophercast play &lt;span class="nt"&gt;--host&lt;/span&gt; &amp;lt;ip_from_serve&amp;gt; &lt;span class="nt"&gt;--port&lt;/span&gt; 8080 &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"client 2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The steps are the same when connecting from multiple machines. Just make sure that all of them are connected to the same LAN.&lt;/p&gt;

&lt;p&gt;Here's a quick demo on a single machine, working as the speaker.&lt;br&gt;
(Ignore the audio quality)&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/cStAJMDMEKw"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;


&lt;h2&gt;
  
  
  My take
&lt;/h2&gt;

&lt;p&gt;I'm pretty happy with how this worked out.&lt;/p&gt;

&lt;p&gt;This was my very first time working with Charm's &lt;a href="https://github.com/charmbracelet/bubbletea" rel="noopener noreferrer"&gt;Bubbletea&lt;/a&gt; and the whole audio stuff.&lt;/p&gt;

&lt;p&gt;I started this project just because I wanted to DIY. It was just the perfect time: my speaker broke, and I had somehow finished learning basic Go programming.&lt;/p&gt;

&lt;p&gt;There's probably still a ton of bugs. I've tried to test most of it. I just picked the tool I knew and figured it out as I went.&lt;/p&gt;

&lt;p&gt;It plays music across multiple devices in sync, or the same one if you connect through multiple terminals. My laptop, another computer, whatever's connected, all playing together.&lt;/p&gt;

&lt;p&gt;That's what I wanted. That's what it does.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftk8fu13vlk1izssz5n7l.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftk8fu13vlk1izssz5n7l.gif" alt="go on GIF" width="480" height="412"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;(I haven't tested how well it works on Mac, so I can't tell there. Also, I'm not sure Windows will work either due to oto's limitation and our hardcoded 50ms delay.)&lt;/p&gt;

&lt;p&gt;You can find the repo here: &lt;a href="https://github.com/shricodev/gophercast.git" rel="noopener noreferrer"&gt;shricodev/gophercast&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🚀 Want to build such cool stuff? You learn hands on here: &lt;a href="https://app.codecrafters.io/join?via=shricodev" rel="noopener noreferrer"&gt;CodeCrafters&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;


&lt;div class="ltag__user ltag__user__id__1127015"&gt;
    &lt;a href="/shricodev" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1127015%2F1c5e48a2-f602-4e7d-8312-3c0322d155c6.jpg" alt="shricodev image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/shricodev"&gt;Shrijal Acharya&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/shricodev"&gt;SDE • GOLD @Microsoft Student Ambassador • Prev Lead Collab and Dev-Team Lead @oppiaorg • Mail for collaboration&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;



</description>
      <category>go</category>
      <category>opensource</category>
      <category>productivity</category>
      <category>programming</category>
    </item>
    <item>
      <title>How to Automate Your Slack Workspace with OpenClaw and Composio 🚀</title>
      <dc:creator>Shrijal Acharya</dc:creator>
      <pubDate>Thu, 16 Apr 2026 15:19:55 +0000</pubDate>
      <link>https://dev.to/composiodev/how-to-automate-your-slack-workspace-with-openclaw-and-composio-3lhc</link>
      <guid>https://dev.to/composiodev/how-to-automate-your-slack-workspace-with-openclaw-and-composio-3lhc</guid>
      <description>&lt;p&gt;Your team already lives in Slack. Code reviews, project updates and what not, it all happens there.&lt;/p&gt;

&lt;p&gt;But the moment you need to file a GitHub issue, check a Linear ticket, or send a follow-up email, you leave Slack, do the thing, and come back. That context switch adds up.&lt;/p&gt;

&lt;p&gt;What if your Slack workspace had an assistant that could do all of that for you, right in the thread. That too an isolated OpenClaw instance per user with admin control? 🤯&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F34fzwp4wnc4g66m15gp2.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F34fzwp4wnc4g66m15gp2.gif" alt="shocked gif" width="480" height="252"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In this article, you'll learn how to automate an entire Slack team workspace that connects to your tools, takes actions, without you ever leaving Slack.&lt;/p&gt;




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

&lt;p&gt;To quickly summarize what we’ll cover in this blog post, here’s what we’ll go through:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The idea behind building a Slack bot around OpenClaw.&lt;/li&gt;
&lt;li&gt;How Composio lets each user connect their own tools.&lt;/li&gt;
&lt;li&gt;How OpenClaw powers replies and tool usage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are a few things you'll understand, but there's so many others you'll learn along the way.&lt;/p&gt;

&lt;p&gt;So, if you want to build a Slack-first (though not limited to) AI with personal tool access for each user, this will give you a solid starting point.&lt;/p&gt;




&lt;h2&gt;
  
  
  What we're building
&lt;/h2&gt;

&lt;p&gt;We're building a Slack bot that brings OpenClaw into Slack.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💁 Not necessarily only for Slack, you can use pretty much the same setup for something like Discord with their SDK or your custom app. The idea remains the same.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Overall the idea is to use OpenClaw and give every user in a workspace their own single instance of it which powers the AI assistant.&lt;/p&gt;

&lt;p&gt;That way, things are isolated per user and the admin can control/limit the toolkits (GitHub, Linear, etc.) the users get access to.&lt;/p&gt;

&lt;p&gt;Each user can connect their own tools with Composio, so the bot can chat, and take actions using the tools they’ve authorized.&lt;/p&gt;

&lt;p&gt;Here's a quick architecture.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fszdytuuqr202h16l1vvs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fszdytuuqr202h16l1vvs.png" alt="bot architecture" width="800" height="386"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Slack and how to create a Slack App?
&lt;/h2&gt;

&lt;p&gt;No big reason to choose Slack, only because it supports slash commands, and it's mostly where people already work.&lt;/p&gt;

&lt;p&gt;For this, we first need to have a Slack app, if you don't already have one, follow the &lt;a href="https://docs.slack.dev/quickstart/" rel="noopener noreferrer"&gt;quickstart guide&lt;/a&gt; to create one.&lt;/p&gt;

&lt;p&gt;Once your app is created, enable Socket Mode so the bot can receive events without exposing a public webhook URL.&lt;/p&gt;

&lt;p&gt;Then add at least these &lt;strong&gt;Bot Token Scopes&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;app_mentions:read&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;chat:write&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;commands&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;im:history&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;im:read&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;users:read&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Subscribe to these &lt;strong&gt;Bot Events&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;app_mention&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;message.im&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And create these &lt;strong&gt;Slash commands&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/connect&lt;/code&gt;: Use it something like &lt;code&gt;/connect &amp;lt;toolkit&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/connections&lt;/code&gt;: User lists active connections&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/help&lt;/code&gt;: Shows usage summary&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/assign&lt;/code&gt;: Admin assigns an OpenClaw instance to a user&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/add-mcp-config&lt;/code&gt;: Admin registers an MCP Config from platform.composio.dev&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/add-auth-config&lt;/code&gt;: Admin links a toolkit to its Composio auth config&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/list-mcp-configs&lt;/code&gt;: Admin lists all registered MCP Configs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Finally copy these values to your &lt;code&gt;.env&lt;/code&gt; file, which you can find in the app settings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SLACK_BOT_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;xoxb-...
&lt;span class="nv"&gt;SLACK_APP_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;xapp-...
&lt;span class="nv"&gt;SLACK_SIGNING_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Your &lt;code&gt;SLACK_BOT_TOKEN&lt;/code&gt; is the bot token itself, &lt;code&gt;SLACK_APP_TOKEN&lt;/code&gt; is the Socket Mode app level token and &lt;code&gt;SLACK_SIGNING_SECRET&lt;/code&gt; is used by Slack to verify requests.&lt;/p&gt;


&lt;h2&gt;
  
  
  How to Set Up the Project
&lt;/h2&gt;

&lt;p&gt;It's fairly simple to get this project up and running. Follow these steps:&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/shricodev/saas-openclaw-slackbot.git
&lt;span class="nb"&gt;cd &lt;/span&gt;saas-openclaw-slackbot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Next, you install the dependencies:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Then set up the environment variables and run the development server:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Slack&lt;/span&gt;
&lt;span class="nv"&gt;SLACK_BOT_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;xoxb-...
&lt;span class="nv"&gt;SLACK_APP_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;xapp-...
&lt;span class="nv"&gt;SLACK_SIGNING_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...

&lt;span class="c"&gt;# Database&lt;/span&gt;
&lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...

&lt;span class="c"&gt;# Composio api key (ak...) from https://platform.composio.dev&lt;/span&gt;
&lt;span class="nv"&gt;COMPOSIO_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"ak_..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;To get the Composio API key:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Log in at &lt;a href="https://platform.composio.dev/" rel="noopener noreferrer"&gt;platform.composio.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Copy your API key (&lt;code&gt;ak_..&lt;/code&gt;) from the Composio dashboard settings, then set it:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjacffrcue8r6sct5ddev.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjacffrcue8r6sct5ddev.png" alt="composio api key" width="800" height="399"&gt;&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Configure Composio Dedicated MCP Server
&lt;/h2&gt;

&lt;p&gt;In this section, we'll go through the process of creating a dedicated MCP server in Composio for each user.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;First, head over to &lt;a href="https://platform.composio.dev" rel="noopener noreferrer"&gt;platform.composio.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Under the MCP Configs tab, create a &lt;strong&gt;Dedicated MCP Server&lt;/strong&gt;. This lets you create MCP servers with specific apps and tools, which is exactly what we want.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo7qlbr4p0vmmtyxkdf3t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo7qlbr4p0vmmtyxkdf3t.png" alt="composio dedicated MCP server creation" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Select all the toolkits you plan to assign for the user and create the MCP server.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo218r8j44g593yqlrvkk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo218r8j44g593yqlrvkk.png" alt="composio toolkits" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;For the External User ID, use the user's Slack user ID. To get someone's Slack user ID, head over to their profile, click the three dots, and select &lt;strong&gt;Copy Member ID&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F25u16lvsh5h94z9d4em2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F25u16lvsh5h94z9d4em2.png" alt="slack user id" width="800" height="410"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Use that as the External User ID.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fs98p5tahcxw9ikfogngu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fs98p5tahcxw9ikfogngu.png" alt="external user id naming composio" width="800" height="192"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Keep note of the &lt;strong&gt;MCP config name&lt;/strong&gt; and &lt;strong&gt;MCP config ID&lt;/strong&gt;. You will need both when configuring the bot in Slack.&lt;/p&gt;

&lt;p&gt;Upon successful creation, you'll find the URL:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv5kwmuvboz09uitypcw3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv5kwmuvboz09uitypcw3.png" alt="composio dedicated mcp server url" width="800" height="394"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You can copy this URL directly and add it to the OpenClaw config, which we’ll cover later in the Configure OpenClaw with Composio section. Alternatively, the bot can fetch it for you after you run the &lt;code&gt;/assign&lt;/code&gt; slash command, which we’ll configure later.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You will also need the auth config ID tied to the tools you selected. In the MCP server, head over to the &lt;strong&gt;Manage Config&lt;/strong&gt; tab and click &lt;strong&gt;Manage Auth Config&lt;/strong&gt;. The auth config ID is listed on that page.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxmo9o16fcq5tmfezu9ua.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxmo9o16fcq5tmfezu9ua.png" alt="composio auth config" width="800" height="394"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Keep note of this as well. You will need it when running &lt;code&gt;/add-auth-config&lt;/code&gt; in Slack.&lt;/p&gt;


&lt;h2&gt;
  
  
  Core Components in the Application
&lt;/h2&gt;

&lt;p&gt;We're not going to code everything from scratch as that'd be too long and impractical. Let's go over some of the core components in the project.&lt;/p&gt;

&lt;p&gt;Before we start with the project core components, here's the project tech stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://slack.dev/bolt-js/" rel="noopener noreferrer"&gt;Slack Bolt&lt;/a&gt;&lt;/strong&gt; - Official Slack bot framework. We use it with Socket Mode, which connects to Slack over a WebSocket without needing a public HTTP endpoint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://openclaw.ai/" rel="noopener noreferrer"&gt;OpenClaw&lt;/a&gt;&lt;/strong&gt; - The agent layer. Exposes an OpenAI-compatible API but acts as a full agentic gateway that plans, calls tools, and reasons over results.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://composio.dev/" rel="noopener noreferrer"&gt;Composio&lt;/a&gt;&lt;/strong&gt; - The core of the project. Manages OAuth connections to external apps like GitHub, Linear, and Gmail, and exposes them to the agent via MCP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TypeScript&lt;/strong&gt; - Obvious choice over JavaScript as we get type safe code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL + Prisma&lt;/strong&gt; - Handles user records, connection status, and per-thread conversation history.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Bootstrapping the Bot
&lt;/h3&gt;

&lt;p&gt;This is where everything starts. We initialize the Slack Bolt app with Socket Mode, register all handlers, and start the server.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 👇 app.ts&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&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;App&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;token&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;SLACK_BOT_TOKEN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;appToken&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;SLACK_APP_TOKEN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;signingSecret&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;SLACK_SIGNING_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;socketMode&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="nf"&gt;registerMessageHandlers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;registerCommandHandlers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &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;port&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&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;PORT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;3000&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Bot is running on port &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (socket mode)`&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;Instead of exposing a public HTTP endpoint for Slack to POST events to, Socket Mode opens a WebSocket connection. This means you can run the bot anywhere could be your local machine, a private server without a public URL.&lt;/p&gt;

&lt;p&gt;If you've worked with bots before, this should be pretty straight-forward to understand. 👀&lt;/p&gt;
&lt;h3&gt;
  
  
  Handling User Messages
&lt;/h3&gt;

&lt;p&gt;This is the brain of the bot. It handles both direct messages and &lt;code&gt;@mentions&lt;/code&gt; in channels.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 👇 message.handler.ts&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleUserMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;message&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="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;channelId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;threadTs&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;await&lt;/span&gt; &lt;span class="nf"&gt;saveMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;slackUserId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;slackTeamId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;channelId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;threadTs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;text&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;history&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getThreadHistory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;slackUserId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;slackTeamId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;channelId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;threadTs&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;priorHistory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&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;thinkingMsg&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="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;channelId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;thread_ts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;threadTs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;_Thinking..._&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generateResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;openclawConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gatewayUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;openclawConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gatewayToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;priorHistory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;sessionKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;slackUserId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;slackTeamId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;channelId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;threadTs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;assistant&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&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="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;channelId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;thinkingMsg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&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;There's a few things you might notice right up:&lt;/p&gt;

&lt;p&gt;First, we store the user's message to the database before sending it to OpenClaw. But why? This way if the request fails, the history isn't broken. Similar to storing chat history in localhost when creating web chat applications.&lt;/p&gt;

&lt;p&gt;I do not know if there’s a better way to handle this, but right now we just show a &lt;code&gt;Thinking...&lt;/code&gt; message while the AI is generating the full response, and then replace it once the final output is ready.&lt;/p&gt;

&lt;p&gt;A little hacky, maybe, but it gets the job done. There are probably better ways to handle this, like streaming the response, but for now the old-school approach works. 😋&lt;/p&gt;
&lt;h3&gt;
  
  
  Slash Commands
&lt;/h3&gt;

&lt;p&gt;The bot exposes seven slash commands split into two groups: user-facing (&lt;code&gt;/connect&lt;/code&gt;, &lt;code&gt;/connections&lt;/code&gt;, &lt;code&gt;/help&lt;/code&gt;) and admin-only (&lt;code&gt;/assign&lt;/code&gt;, &lt;code&gt;/add-mcp-config&lt;/code&gt;, &lt;code&gt;/add-auth-config&lt;/code&gt;, &lt;code&gt;/list-mcp-configs&lt;/code&gt;).&lt;/p&gt;
&lt;h4&gt;
  
  
  /connect
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;/connect &amp;lt;toolkit&amp;gt;&lt;/code&gt; starts an OAuth flow for a tool like GitHub or Gmail. But unlike the previous version where any user could connect any toolkit, now the bot checks three things before starting a connection:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Does this user have an MCP Config assigned?&lt;/li&gt;
&lt;li&gt;Is the requested toolkit in that config?&lt;/li&gt;
&lt;li&gt;Is there an auth config registered for this toolkit?
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 👇 command.handler.ts&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&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;/connect&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ack&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;respond&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;await&lt;/span&gt; &lt;span class="nf"&gt;ack&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;toolkitSlug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&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;apiKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getComposioApiKey&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;assignment&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mcpConfigAssignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUnique&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;slackUserId_slackTeamId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;slackUserId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;slackTeamId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;team_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="na"&gt;include&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;mcpConfig&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="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;assignment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;response_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;ephemeral&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;You have not been assigned an MCP Config. Ask your admin to run `/assign`.&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="k"&gt;return&lt;/span&gt;&lt;span class="p"&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;assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mcpConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;toolkitSlugs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;toolkitSlug&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;response_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;ephemeral&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;This toolkit is not available in your assigned config. &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Your admin controls which toolkits you can access.&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="k"&gt;return&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;toolkitAuth&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mcpToolkitAuth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUnique&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;slackTeamId_toolkitSlug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;slackTeamId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;team_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;toolkitSlug&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;// check if already connected&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;connectedToolkits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getConnectedToolkits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user_id&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;connectedToolkits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;toolkitSlug&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;response_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;ephemeral&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`You're already connected to *&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;toolkitSlug&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="k"&gt;return&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;redirectUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;initiateConnection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;toolkitAuth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authConfigId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;response_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;ephemeral&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Click here to connect *&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;toolkitSlug&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="nx"&gt;redirectUrl&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;The response is only visible to the user who ran the command. That's intentional as OAuth URLs are personal and shouldn't be visible to the whole channel.&lt;/p&gt;
&lt;h4&gt;
  
  
  /assign
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;/assign&lt;/code&gt; is admin-only and lets admins assign a specific OpenClaw gateway to a user. It opens a Slack modal to collect the gateway URL, token and MCP config server ID.&lt;/p&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 👇 command.service.ts&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&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;/assign&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ack&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;respond&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ack&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;userInfo&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="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user_id&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;isAdmin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;userInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;is_admin&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;userInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;is_owner&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;isAdmin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;response_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;ephemeral&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Only workspace admins can assign OpenClaw instances.&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="k"&gt;return&lt;/span&gt;&lt;span class="p"&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="nx"&gt;views&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;trigger_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;trigger_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;view&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;assignInstanceModal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// includes gateway URL, token, and MCP Config ID fields&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 user's MCP URL looks something like this: &lt;code&gt;https://backend.composio.dev/v3/mcp/aaa-111/mcp?user_id=&amp;lt;slack_user_id&amp;gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;It's the key because it's what connects the user's OpenClaw instance to the toolkits the admin selected for them.&lt;/p&gt;
&lt;h4&gt;
  
  
  /add-mcp-config and /add-auth-config
&lt;/h4&gt;

&lt;p&gt;These two admin commands register the Composio resources in the bot's database. Both open modals.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/add-mcp-config&lt;/code&gt; registers an MCP Config by name and server ID:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// On modal submit:&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mcpConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;slackTeamId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;teamId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;composioServerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// the UUID from the MCP URL&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// e.g. "Engineering"&lt;/span&gt;
    &lt;span class="nx"&gt;toolkitSlugs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// e.g. ["github", "linear"]&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;code&gt;/add-auth-config&lt;/code&gt; links a toolkit slug to its Composio auth config ID. This is what &lt;code&gt;/connect&lt;/code&gt; uses to know which auth config to pass when initiating a connection:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// On modal submit:&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mcpToolkitAuth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;slackTeamId_toolkitSlug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;slackTeamId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;teamId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;toolkitSlug&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;slackTeamId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;teamId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;toolkitSlug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;authConfigId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;authConfigId&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;h4&gt;
  
  
  /list-mcp-configs
&lt;/h4&gt;

&lt;p&gt;A simple admin command that lists all registered MCP Configs for the workspace:&lt;/p&gt;

&lt;p&gt;"Engineering" - server:  - toolkits: github, linear&lt;br&gt;
"Sales" - server:  - toolkits: gmail, notion&lt;/p&gt;
&lt;h3&gt;
  
  
  Sending Requests to OpenClaw
&lt;/h3&gt;

&lt;p&gt;Up until this point we were working on the Slack side and a bit of Composio setup, but how do we actually send these messages to OpenClaw?&lt;/p&gt;

&lt;p&gt;OpenClaw exposes an OpenAI-compatible &lt;code&gt;/v1/chat/completions&lt;/code&gt; endpoint. Our service code wraps that with a proper system prompt, conversation history, and error handling. Nothing super unknown to most of you.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 👇 openclaw.service.ts&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;gatewayUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;gatewayToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;userMessage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;sessionKey&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;OpenClawResponse&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="na"&gt;systemPrompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ChatMessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;You are a helpful assistant in a Slack workspace. &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;You have access to the user's connected tools (GitHub, Linear, Gmail, etc.) through Composio. &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;The user's tools are already connected. Do not ask them to connect or authenticate. &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Use the available Composio tools directly to answer questions. &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Be concise. Format responses for Slack (use mrkdwn syntax).&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="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nx"&gt;systemPrompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;history&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;m&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;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&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;user&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;assistant&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;assistant&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&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;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userMessage&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="nf"&gt;sendToOpenClaw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gatewayUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;gatewayToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sessionKey&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;We also wrap the raw fetch in a custom &lt;code&gt;OpenClawError&lt;/code&gt; class with error codes for timeouts, auth failures, and gateway errors. Preferred thing you do in a real-world codebase.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 Prefer something built-in like &lt;code&gt;fetch&lt;/code&gt; over third-party tool like &lt;code&gt;axios&lt;/code&gt;. Especially now after the recent compromise of &lt;code&gt;axios&lt;/code&gt; which is used by hundreds and thousands of applications.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  Connecting tools with Composio
&lt;/h3&gt;

&lt;p&gt;You might be familiar working with Composio over the SDK &lt;code&gt;@composio/core&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But with Composio, you can also directly talk to it's REST API. We now talk to the Composio REST API on &lt;code&gt;backend.composio.dev&lt;/code&gt; using an API key from &lt;code&gt;platform.composio.dev&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There are only three functions in the service, and each one does exactly what the name suggests 🤌&lt;/p&gt;
&lt;h4&gt;
  
  
  Check what a user has connected:
&lt;/h4&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 👇 composio.service.ts&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getConnectedToolkits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;slackUserId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;COMPOSIO_API_BASE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/connected_accounts?user_id=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slackUserId&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&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&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&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;x-api-key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&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;data&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&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;account&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;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ACTIVE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;account&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;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;toolkit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&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;h4&gt;
  
  
  Initiate a new connection:
&lt;/h4&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 👇 composio.service.ts&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;initiateConnection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;authConfigId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;slackUserId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;COMPOSIO_API_BASE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/connected_accounts`&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&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;Content-Type&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;application/json&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;x-api-key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&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;auth_config&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="nx"&gt;authConfigId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;slackUserId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&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;data&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;redirect_url&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;h4&gt;
  
  
  Build the per-user MCP URL:
&lt;/h4&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 👇 composio.service.ts&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getMcpUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;composioServerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;slackUserId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`https://backend.composio.dev/v3/mcp/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;composioServerId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/mcp?user_id=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slackUserId&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This last one is the most important. There's no API call, it's pure URL construction. But this URL is what ties everything together: the &lt;code&gt;composioServerId&lt;/code&gt; controls which toolkits are available, and&lt;br&gt;
the &lt;code&gt;user_id&lt;/code&gt; scopes which credentials are used. When &lt;code&gt;/assign&lt;/code&gt; runs, it computes this URL and shows it to the admin so they can configure it in the user's OpenClaw instance.&lt;/p&gt;
&lt;h3&gt;
  
  
  Persisting Users and Conversation
&lt;/h3&gt;

&lt;p&gt;Every Slack user that messages the bot gets a record in our database keyed on (slackUserId, slackTeamId) pair. This is a safety net, as the same Slack user ID could theoretically exist across different workspaces.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 👇 slack-user.service.ts&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resolveSlackUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;slackUserId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;slackTeamId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="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;existing&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slackUser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUnique&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;slackUserId_slackTeamId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;slackUserId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;slackTeamId&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existing&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;existing&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slackUser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;slackUserId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;slackTeamId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;composioEntityId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`slack_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slackTeamId&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="nx"&gt;slackUserId&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Conversation history is stored per-thread using Slack's &lt;code&gt;thread_ts&lt;/code&gt; (the timestamp of the first message in a thread) as the thread id. When the bot receives a message, it fetches the full thread history and passes it to OpenClaw, giving it the memory for the duration of that thread.&lt;/p&gt;


&lt;h2&gt;
  
  
  Configuration Setup
&lt;/h2&gt;

&lt;p&gt;The bot requires per-user OpenClaw instances assigned by an admin. If a user hasn't been assigned an instance, they can't use any features. &lt;code&gt;/connect&lt;/code&gt;, &lt;code&gt;/connections&lt;/code&gt; and chat all require an assignment first.&lt;/p&gt;
&lt;h3&gt;
  
  
  Why not use shared instance?
&lt;/h3&gt;

&lt;p&gt;By shared instance, I mean all the users share the same OpenClaw instance. So why not use it that way? That's how server is supposed to work?&lt;/p&gt;

&lt;p&gt;There's a few reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;By default, OpenClaw is not designed to support multiple users connecting to the same gateway concurrently. In practice, which is likely to be the case for our use case.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;This is already the main reason.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;Also, in general, letting multiple users use the same instance with multiple connected accounts is not safe. A prompt injection by one user &lt;strong&gt;could&lt;/strong&gt; access or destroy another user's data.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;Even if there's safety measure (which I'm not aware of). Things could always go wrong. So better safe than sorry.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 👇 lib/config.ts&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getUserOpenClawConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;slackUserId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;slackTeamId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;OpenClawConfig&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;user&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slackUser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUnique&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;slackUserId_slackTeamId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;slackUserId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;slackTeamId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;select&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;openclawGatewayUrl&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;gatewayToken&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;openclawGatewayUrl&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;gatewayToken&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;gatewayUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;openclawGatewayUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;gatewayToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gatewayToken&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;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;No OpenClaw instance assigned. Ask your admin to run /assign.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;No assignment, no access. The admin runs &lt;code&gt;/assign&lt;/code&gt; for each user, providing their OpenClaw gateway URL, token, and MCP Config. Until that happens, the bot won't respond to that user.&lt;/p&gt;


&lt;h2&gt;
  
  
  Configure OpenClaw with Composio
&lt;/h2&gt;

&lt;p&gt;Great, now the code part is done. There's one thing that's still left.&lt;/p&gt;

&lt;p&gt;Now, the actual reason to build the bot i.e. to get tools access is not configured &lt;strong&gt;within OpenClaw&lt;/strong&gt; which we do with Composio. It's the most easiest.&lt;/p&gt;

&lt;p&gt;There's multiple ways to configure OpenClaw with Composio. There's standard ways you can find &lt;a href="https://composio.dev/claw" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;But, we won't follow the standard way, as by default it uses consumer key, which is a way it's designed by default.&lt;/p&gt;

&lt;p&gt;But we won't work with consumer key, we directly work with the MCP URL.&lt;/p&gt;

&lt;p&gt;Go ahead and modify the OpenClaw config file which lives in the &lt;code&gt;~/.openclaw/openclaw.json&lt;/code&gt; with the following:&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="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;rest&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;config...&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="nl"&gt;"plugins"&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;"allow"&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="s2"&gt;"composio"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"...rest"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"entries"&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;"telegram"&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;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"composio"&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;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"config"&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;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;put&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;MCP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;URL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;you&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;receive&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;after&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;running&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/assign&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;user.&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"mcpUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="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;This sets up &lt;strong&gt;one instance for one user&lt;/strong&gt;. But how do you about configuring multiple instances for multiple users?&lt;/p&gt;
&lt;h3&gt;
  
  
  How do you run it for multiple users?
&lt;/h3&gt;

&lt;p&gt;This only configures one user in the entire workspace. But what about the rest?&lt;/p&gt;

&lt;p&gt;There are a few ways:&lt;/p&gt;
&lt;h4&gt;
  
  
  1. Separate machine or VMs:
&lt;/h4&gt;

&lt;p&gt;Each user's OpenClaw runs on a different machine. Each has its own ~/.openclaw/openclaw.json with its own MCP URL. This is the cleanest but most expensive.&lt;/p&gt;
&lt;h4&gt;
  
  
  2. Use named OpenClaw profiles:
&lt;/h4&gt;

&lt;p&gt;OpenClaw ships with a &lt;code&gt;--profile&lt;/code&gt; flag out of the box:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  &lt;span class="nt"&gt;--profile&lt;/span&gt; &amp;lt;name&amp;gt;     Use a named profile &lt;span class="o"&gt;(&lt;/span&gt;isolates OPENCLAW_STATE_DIR/OPENCLAW_CONFIG_PATH under ~/.openclaw-&amp;lt;name&amp;gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;You can use a different profile per user. If you name each profile after the user, you get an isolated config for each one on the same machine. Most efficient.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openclaw &lt;span class="nt"&gt;--profile&lt;/span&gt; bob
openclaw &lt;span class="nt"&gt;--profile&lt;/span&gt; shrijal
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h4&gt;
  
  
  3. Separate OS users on one machine:
&lt;/h4&gt;

&lt;p&gt;Somewhat impractical. You'd run one OpenClaw instance per OS user, which means creating a separate system account for each person. Possible, but not a great approach.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;There could be hundreds of other ways to do it. These are just the ones I could think of. Do your own research, and you’ll probably find others.&lt;/p&gt;
&lt;/blockquote&gt;


&lt;h2&gt;
  
  
  Slack Workflow
&lt;/h2&gt;

&lt;p&gt;Run these commands in order as an admin before any user can start chatting.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Register your MCP Configs (one per config you created on platform.composio.dev):
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/add-mcp-config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ol&gt;
&lt;li&gt;Register auth configs (one per toolkit):
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/add-auth-config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ol&gt;
&lt;li&gt;Assign each user their OpenClaw instance and MCP Config:
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/assign
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This gives you the user's scoped MCP URL. Configure it in their OpenClaw instance.&lt;/p&gt;

&lt;p&gt;Once assigned, users run:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/connect &amp;lt;toolkit&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That's it. After connecting, they can DM the bot or &lt;code&gt;@mention&lt;/code&gt; it in a channel.&lt;/p&gt;


&lt;h2&gt;
  
  
  Bot in Action
&lt;/h2&gt;

&lt;p&gt;Here's a quick demo of the bot in action:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/CbXwWr4h5LM"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;


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

&lt;p&gt;So yeah, that's the whole idea.&lt;/p&gt;

&lt;p&gt;A Slack bot on top of OpenClaw, with Composio handling user tool connections, ends up being a really solid setup.&lt;/p&gt;

&lt;p&gt;At this point, you’ve got a good idea of how this bot works with Slack, OpenClaw, and Composio.&lt;/p&gt;

&lt;p&gt;We covered the main flow, how users connect their tools, how everything comes together inside Slack, and why assigning one OpenClaw instance per user helps keep things isolated.&lt;/p&gt;

&lt;p&gt;It keeps the setup clean and gives you a bot that’s actually useful.&lt;/p&gt;

&lt;p&gt;That's all for this one.&lt;/p&gt;

&lt;p&gt;You can find the entire source code here: &lt;a href="https://github.com/shricodev/saas-openclaw-slackbot" rel="noopener noreferrer"&gt;shricodev/saas-openclaw-slackbot&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag__user ltag__user__id__1127015"&gt;
    &lt;a href="/shricodev" class="ltag__user__link profile-image-link"&gt;
      &lt;div class="ltag__user__pic"&gt;
        &lt;img src="https://media2.dev.to/dynamic/image/width=150,height=150,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1127015%2F1c5e48a2-f602-4e7d-8312-3c0322d155c6.jpg" alt="shricodev image"&gt;
      &lt;/div&gt;
    &lt;/a&gt;
  &lt;div class="ltag__user__content"&gt;
    &lt;h2&gt;
&lt;a class="ltag__user__link" href="/shricodev"&gt;Shrijal Acharya&lt;/a&gt;Follow
&lt;/h2&gt;
    &lt;div class="ltag__user__summary"&gt;
      &lt;a class="ltag__user__link" href="/shricodev"&gt;SDE • GOLD @Microsoft Student Ambassador • Prev Lead Collab and Dev-Team Lead @oppiaorg • Mail for collaboration&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;



</description>
      <category>ai</category>
      <category>productivity</category>
      <category>programming</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
