<?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: Chirag Aggarwal</title>
    <description>The latest articles on DEV Community by Chirag Aggarwal (@chiragagg5k).</description>
    <link>https://dev.to/chiragagg5k</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%2F963052%2F5823e752-1d03-4f78-96ae-b9beec03752f.png</url>
      <title>DEV Community: Chirag Aggarwal</title>
      <link>https://dev.to/chiragagg5k</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/chiragagg5k"/>
    <language>en</language>
    <item>
      <title>Keeping track of your agent's work</title>
      <dc:creator>Chirag Aggarwal</dc:creator>
      <pubDate>Mon, 26 Oct 2026 12:47:20 +0000</pubDate>
      <link>https://dev.to/chiragagg5k/keeping-track-of-your-agents-work-1g28</link>
      <guid>https://dev.to/chiragagg5k/keeping-track-of-your-agents-work-1g28</guid>
      <description>&lt;p&gt;Agents are long-running LLMs, and long-running LLMs lose context. Not sometimes. Eventually, almost always.&lt;/p&gt;

&lt;p&gt;The agent doesn't tell you when it happens. It compacts, summarises, or starts a new session, and whatever didn't make the cut is gone. The worse part is that you lose context too, and you usually don't notice either.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the context goes missing
&lt;/h2&gt;

&lt;p&gt;Three things keep happening to me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The agent finds something important and mentions it once.&lt;/strong&gt; Halfway through a task it notices a second bug, a migration that will break existing data, or a flag that does something nobody expected. It writes one line about it, carries on with what you asked for, and never brings it up again. After the next compaction it doesn't know either.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You can't see what it forgot.&lt;/strong&gt; When I lose track of my own work, I at least know I've lost it. With an agent the context is invisible. I don't know what's in its window, what got summarised away, or which decision from two hours ago is still being honoured.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It writes more than you can read.&lt;/strong&gt; An agent can produce pages of output, plans, and reasoning in the time it takes me to read one paragraph. Most of it is fine. Some of it matters. Finding the part that matters takes longer than the agent took to write all of it, so I skim, and skimming is how the one important line gets missed.&lt;/p&gt;

&lt;p&gt;Put those together and the important stuff ends up nowhere: not in the agent's context, not in your head, and buried somewhere in a transcript nobody will read again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give every piece of work a ticket
&lt;/h2&gt;

&lt;p&gt;The fix I've settled on is boring: an issue tracker. One place for each feature or fix I'm working on, where the state of that work lives outside both the agent and me.&lt;/p&gt;

&lt;p&gt;A few rules make it work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope it however you like.&lt;/strong&gt; A ticket can be as broad as "ship passkeys" or as narrow as "fix the logo on the consent screen". When something gets big, split it into sub-issues instead of growing one giant description. The parent says what we're doing and why. The children say what's left.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It has to make sense to you.&lt;/strong&gt; This is the whole point. If the ticket reads like the agent's scratchpad, you've just moved the gibberish somewhere else. Decisions, open questions, things that were found along the way, what's done, what isn't. Short enough that I can read it in a minute and know where things stand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The agent keeps it up to date, always.&lt;/strong&gt; Not at the end. Every time it finds something, decides something, or finishes a step, it goes in the ticket. That one-line mention of a second bug becomes a sub-issue instead of a sentence that disappears after the next compaction.&lt;/p&gt;

&lt;h2&gt;
  
  
  The side benefit: anyone can pick it back up
&lt;/h2&gt;

&lt;p&gt;Once the state lives in the ticket, the session stops mattering.&lt;/p&gt;

&lt;p&gt;If I hit a usage limit mid-task, or a session dies, or I just close the laptop and come back a day later, a fresh agent can read the ticket and carry on. It doesn't need the old transcript or me re-explaining everything. The same goes for a different agent, or a teammate, or me in a week when I've forgotten all of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Examples
&lt;/h2&gt;

&lt;p&gt;All of these are from the last month of my own sessions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mentioned once, then gone.&lt;/strong&gt; While fixing text responses in the SDK generator (&lt;a href="https://github.com/appwrite/sdk-generator/pull/1944" rel="noopener noreferrer"&gt;#1944&lt;/a&gt;), the agent noticed Dart models type nullable fields as non-null and offered to leave it out of the PR. I never replied. It never came up again, and the template still only checks whether a field is required.&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%2F0vddg54mk72qmk8gbnv7.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%2F0vddg54mk72qmk8gbnv7.webp" alt="Line 12 of the Dart model template on main, adding a question mark only when a property is not required" width="800" height="169"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Still on main a month later (model.dart.twig)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A ticket that reads like a status page.&lt;/strong&gt; &lt;a href="https://github.com/appwrite/appwrite/issues/13828" rel="noopener noreferrer"&gt;#13828&lt;/a&gt; tracked moving our libraries into the monorepo in waves. The agent rewrote the top of it after each wave, so "where we are" was always one glance away.&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%2Fipupmg417abbeev0sjpy.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%2Fipupmg417abbeev0sjpy.webp" alt="GitHub issue 13828 with a Where we are section: waves 1 to 5 done, 46 packages live" width="800" height="357"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;#13828, kept current by the agent&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A finding becomes its own issue.&lt;/strong&gt; While researching something else, an agent found endpoints returning a 500 for values too long for their column. It filed &lt;a href="https://github.com/appwrite/appwrite/issues/14293" rel="noopener noreferrer"&gt;#14293&lt;/a&gt; instead of mentioning it in passing, so I could hand it to the database team. It's closed now.&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%2F5rl4faoo6cv83nywurr3.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%2F5rl4faoo6cv83nywurr3.webp" alt="GitHub issue 14293 summary: several endpoints return 500 general_unknown instead of 400" width="800" height="184"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;#14293, filed by the agent&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Parked work anyone can resume.&lt;/strong&gt; When we deferred the last wave, it got its own issue, &lt;a href="https://github.com/appwrite/appwrite/issues/14255" rel="noopener noreferrer"&gt;#14255&lt;/a&gt;, with the branch, the commit and exactly what to do next. Any session, or person, can start from here.&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%2Fov8dem3xszjqg15llxdx.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%2Fov8dem3xszjqg15llxdx.webp" alt="GitHub issue 14255: the work is done on branch chore/absorb-database at head 9f26684; reopen the PR, merge main and re-run CI" width="800" height="276"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;#14255, the handoff for wave 6&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone starting
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Assume your agent will lose context. Plan for it instead of hoping it won't.&lt;/li&gt;
&lt;li&gt;Give every feature or fix a ticket. Split big ones into sub-issues.&lt;/li&gt;
&lt;li&gt;Write tickets for a human. If you can't read it in a minute, it's not doing its job.&lt;/li&gt;
&lt;li&gt;Make the agent update the ticket as it goes, not at the end.&lt;/li&gt;
&lt;li&gt;Treat the ticket as the handoff. Any session should be able to start from it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The agent will forget. The ticket won't.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.chiragaggarwal.tech/blog/keeping-track-of-your-agents-work" rel="noopener noreferrer"&gt;chiragaggarwal.tech&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>agents</category>
      <category>github</category>
    </item>
    <item>
      <title>A Master's in Ireland While Building Appwrite</title>
      <dc:creator>Chirag Aggarwal</dc:creator>
      <pubDate>Mon, 26 Oct 2026 12:37:04 +0000</pubDate>
      <link>https://dev.to/chiragagg5k/a-masters-in-ireland-while-building-appwrite-4p7b</link>
      <guid>https://dev.to/chiragagg5k/a-masters-in-ireland-while-building-appwrite-4p7b</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I moved to Ireland on August 30, 2026 to start an MSc in Software Engineering at the University of Limerick, and I'm still working full-time as a Platform Engineer at Appwrite. It's been just over a month, my first midterm is literally tomorrow, and enough has happened that I wanted to write it down: how I got here, why I came, and what comes next.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why a Master's, and why now
&lt;/h2&gt;

&lt;p&gt;Not many people know this, but I started working at Appwrite while I was still doing my bachelor's at Bennett University in India. I joined as an intern for six months and then moved to full-time. By the time I graduated in May 2026, I'd already been at Appwrite for a year and a half.&lt;/p&gt;

&lt;p&gt;Doing both at once never felt like a compromise. I loved the work and I loved studying, and the two fed into each other in a way I wasn't ready to give up. So instead of picking one, I signed up for another degree.&lt;/p&gt;

&lt;p&gt;There's a second reason, and it has more to do with where the industry is right now. With AI, you can prompt almost any feature into existence. It's very easy (and I'm guilty of this too) to take a request from a product person, paste it into a Claude Code session and ship whatever comes out, without putting any real thought into it.&lt;/p&gt;

&lt;p&gt;That's exactly why I think the fundamentals matter more now, not less. Knowing how to design a system, and why some designs hold up while others fall apart, is the part an agent can't do for you. One month in, I'm reading Martin Fowler, Grady Booch and the Gang of Four on design patterns. Yes, I'm only now properly meeting these people, and yes, I'm enjoying it a lot.&lt;/p&gt;

&lt;p&gt;Work teaches you a lot, but mostly it teaches you whatever this week's problem needs. A degree gives me room to step back and learn the reasoning behind patterns I already use every day, and to put names to things I'd only ever picked up by osmosis. Once you can name a pattern, you can spot it, question it and explain it to someone else, which matters a lot more when an agent is writing half the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Ireland
&lt;/h2&gt;

&lt;p&gt;Ireland is tiny compared to India, but it punches well above its size. I kept hearing about new startups coming out of it, the tech scene keeps growing, and most importantly the education looked hands-on and practical. That was especially true of the University of Limerick, which is where I ended up.&lt;/p&gt;

&lt;p&gt;Dublin was a close second. It's where most international students go, it has more universities, and it's a much denser city. Limerick felt calmer, and that was exactly what I wanted.&lt;/p&gt;

&lt;p&gt;The visa took a long time. A very long time. It was my first visa application, so I had no idea what to expect. Collecting documents alone took weeks, mostly because my university was slow to issue my provisional degree. Once I applied, the decision took about five weeks and arrived uncomfortably close to the start of my course. If you're applying, start earlier than you think you need to.&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%2Fmfcrq99o2x583rw4qbfj.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%2Fmfcrq99o2x583rw4qbfj.jpg" alt="Chirag with Wolfie, the UL Student Life mascot, on campus" width="800" height="1067"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A picture with Wolfie, the mascot of UL Student Life&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping Appwrite in the picture
&lt;/h2&gt;

&lt;p&gt;I've always been open with the team about my plans, and they've backed every decision I've made. Working at Appwrite has never felt like something I had to force. I get to build technology I care about with what is honestly the best team I could ask for, and it has always felt like home. (Literally, since I work remotely. But still, I've never once wanted to quit.) Thankfully the team felt the same way about me, so the work continues!&lt;/p&gt;

&lt;p&gt;What has changed is how I work. Lectures often clash with my sync meetings, so a lot more of my work is async now. That has forced better habits on me: planning everything ahead, keeping blockers to a minimum, and communicating more clearly.&lt;/p&gt;

&lt;p&gt;The communication part is the one I feel most. I have fewer hours to unblock someone, so if a misunderstanding is my fault, it costs more than it used to. An async message has to stand on its own: what I did, where I'm stuck and what I need from whom. If I leave a gap, someone waits half a day for me to come out of a lecture and fill it.&lt;/p&gt;

&lt;p&gt;Being fully remote has plenty of perks, and being able to do all of this from a different country is one of them :D&lt;/p&gt;

&lt;h2&gt;
  
  
  The move
&lt;/h2&gt;

&lt;p&gt;Ireland's housing crisis is as bad as everyone says. Just a few days ago, students in Dublin were protesting over exactly this. I was lucky to get on-campus accommodation, so I can walk to my classes and make it to the university gym or a tennis session at a few minutes' notice.&lt;/p&gt;

&lt;p&gt;The thing nobody really prepares you for is the weather. Everyone warns you about the rain, but it doesn't land until you live in it. "Constant rain" sounds like a proper downpour, and usually it isn't. It's more of a background process: a light drizzle that never quite stops and keeps the roads permanently wet. Then, at the most inconvenient moments, like when I need to walk to class, it turns into a small storm.&lt;/p&gt;

&lt;p&gt;I'm also still getting used to cooking for myself, doing my own groceries and learning a whole new set of food brands. A lot of things taste better here. I will say, though, that restaurant food and street food were better back in India :P&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%2Ftpy0cg4xhhlj3hz2p2pk.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%2Ftpy0cg4xhhlj3hz2p2pk.jpg" alt="Chirag at the Cliffs of Moher" width="800" height="1067"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Visiting the Cliffs of Moher on a guided trip by UL Student Life&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm looking forward to
&lt;/h2&gt;

&lt;p&gt;There's so much here I want to try. I've already started playing tennis again, which I played a lot in high school but hadn't touched in about four years. With the courts a two-minute walk from my room, it's very hard to find excuses.&lt;/p&gt;

&lt;p&gt;I'm also going to far more events than I used to. At a small computer society event, I built a heartbeat monitor, my first ever hardware project. I spend my days on servers and APIs, so wiring up a sensor on a breadboard and watching my own pulse appear on a tiny screen was a completely different kind of fun.&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%2Fy8mqmnz5gnsewbjnwx80.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%2Fy8mqmnz5gnsewbjnwx80.jpg" alt="A breadboard heartbeat monitor with an OLED screen showing 88 bpm and a pulse waveform" width="800" height="600"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;My first ever hardware project: a heartbeat monitor. Verdict: normal human&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Events aren't limited to Limerick either. I went up to Dublin for the MongoDB student hackathon during Dublin AI Week, which was a great excuse to see a bit more of the country and meet students from other universities, including one from Trinity College Dublin who had a much shorter trip than I did.&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%2Folw7jaf04gnpsndiczg5.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%2Folw7jaf04gnpsndiczg5.jpg" alt="Chirag with three others holding MongoDB tote bags in front of a sponsor wall" width="800" height="600"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The MongoDB student hackathon, part of Dublin AI Week&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;There's also a real overlap between what I'm studying and what I build at Appwrite. Modules like Advanced Software Design are all about writing enterprise-grade software that is reusable, extensible and maintainable. That's not abstract for me. Appwrite is a large, open source codebase that a lot of people depend on, and every shortcut we take there gets copied, extended and maintained for years.&lt;/p&gt;

&lt;p&gt;A lot of what I'm reading about already exists in that codebase in some form. Utopia's database library, which Appwrite is built on, gives you one interface with a separate adapter for each database engine underneath, which is pretty much a textbook example from the patterns books. Learning the theory behind code I already work in every day makes both of them click a lot faster, and I'm excited to bring more of it back into my work.&lt;/p&gt;

&lt;h2&gt;
  
  
  One month in
&lt;/h2&gt;

&lt;p&gt;It's busy. Lectures, assignments and Appwrite all want my time, and there's a midterm tomorrow that I should probably be revising for instead of writing this :P But it's the good kind of busy. What I learn in class connects straight back to my work, and outside of both I'm doing things I hadn't done in years. Ask me again after exams, but so far I'm really glad I made the jump.&lt;/p&gt;

&lt;h2&gt;
  
  
  Thank You
&lt;/h2&gt;

&lt;p&gt;To the Appwrite team, for backing this from the very first conversation and making it possible to keep doing work I love from a new country. To UL Student Life, for Wolfie, the Cliffs of Moher and making a new place feel welcoming so quickly. And to my family and friends back home. India is four and a half hours ahead, so my evenings are your late nights, and our calls have moved to my mornings. Thank you for working around that so I can be here.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;You can check out my profile to learn more about me: &lt;a href="https://www.chiragaggarwal.tech/" rel="noopener noreferrer"&gt;https://www.chiragaggarwal.tech/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you're thinking about studying in Ireland or juggling work and a degree, hit me up on any of my socials. I'm always up for a chat :D&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.chiragaggarwal.tech/blog/a-masters-in-ireland-while-building-appwrite" rel="noopener noreferrer"&gt;chiragaggarwal.tech&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>career</category>
      <category>appwrite</category>
      <category>learning</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>My agent has two Playwrights</title>
      <dc:creator>Chirag Aggarwal</dc:creator>
      <pubDate>Mon, 26 Oct 2026 12:36:32 +0000</pubDate>
      <link>https://dev.to/chiragagg5k/my-agent-has-two-playwrights-5e6g</link>
      <guid>https://dev.to/chiragagg5k/my-agent-has-two-playwrights-5e6g</guid>
      <description>&lt;p&gt;My agent made 1,929 browser calls through Playwright MCP last month, and almost none of them were tests.&lt;/p&gt;

&lt;p&gt;That surprised me, because I do have tests. The Appwrite console has a &lt;code&gt;playwright.config.ts&lt;/code&gt;, CI runs four Console E2E jobs on every pull request, and I spent part of the month fixing flakes in them. None of the 1,929 calls, spread over 62 sessions in my Claude Code transcripts between 17 September and 17 October, ran that suite. They used the browser for something else.&lt;/p&gt;

&lt;p&gt;So my agent has two Playwrights, and I've stopped treating them as one tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two jobs
&lt;/h2&gt;

&lt;p&gt;The test suite answers one question: did something that used to work stop working? It runs headless in CI, it's mostly deterministic, and it doesn't care what anything looks like.&lt;/p&gt;

&lt;p&gt;Playwright MCP answers a different one: does the thing I just built look right? Nobody has written an assertion for that, because an hour ago the thing didn't exist. The agent opens a real browser, goes to the page, clicks through it, reads it back and takes a picture.&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%2F9vngxayza2y7kjxzlhj1.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%2F9vngxayza2y7kjxzlhj1.png" alt="1,929 Playwright MCP calls by tool: navigate 372, take_screenshot 332, evaluate 304, click 247, run_code_unsafe 188, wait_for 160, snapshot 120, resize 45, other 10 tools 161" width="800" height="407"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Interactive version on &lt;a href="https://www.chiragaggarwal.tech/blog/my-agent-has-two-playwrights" rel="noopener noreferrer"&gt;the original post&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Screenshots are the second most common call overall. In the sessions where the agent was checking its own work, they're the most common: 281 screenshots against 226 navigations, and 209 of those navigations went to &lt;code&gt;localhost&lt;/code&gt; or a local file. It isn't browsing the web. It's looking at what it just made.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;browser_evaluate&lt;/code&gt; is mostly reading, not poking. Of its 304 calls plus 188 &lt;code&gt;run_code_unsafe&lt;/code&gt; calls, 175 use &lt;code&gt;querySelectorAll&lt;/code&gt; and 151 read &lt;code&gt;innerText&lt;/code&gt;. A handful flip the &lt;code&gt;dark&lt;/code&gt; class on &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; so the next screenshot shows the other theme.&lt;/p&gt;

&lt;p&gt;This is what one of those pictures looks like. When Appwrite added function intervals as an alternative to cron, the agent built the console picker, then opened it in the browser and screenshotted every state before the PR went up.&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%2Fw9dia6lpzi5ckq5gffi1.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%2Fw9dia6lpzi5ckq5gffi1.webp" alt="Appwrite console function settings with the Interval tab selected and a dropdown of presets from every 5 minutes to daily" width="800" height="556"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The interval picker, checked by the agent in a local console before review (#14319)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the calls went
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What the session was for&lt;/th&gt;
&lt;th&gt;Sessions&lt;/th&gt;
&lt;th&gt;Calls&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Checking a UI, docs or release change&lt;/td&gt;
&lt;td&gt;34&lt;/td&gt;
&lt;td&gt;1,209&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chores in logged-in web apps&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;553&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Research&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;167&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nearly two thirds is verification. The rest is a different story, which I'll come back to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three sessions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  "The logo looks off"
&lt;/h3&gt;

&lt;p&gt;I pasted a screenshot of the Appwrite CLI's device sign-in screen and typed four words: "the logo looks off, debug why". The logo was a black mark sitting on a near-black card.&lt;/p&gt;

&lt;p&gt;The asset was fine. Every official app logo in that storage bucket is black on transparent, on purpose. The marketplace already knew this: its logo component puts the image on a muted tile and inverts it in dark mode. The OAuth consent screens just didn't use that component.&lt;/p&gt;

&lt;p&gt;The fix was to move it into a shared &lt;code&gt;AppLogo&lt;/code&gt; and use it in both places. The part I care about came after. I asked the agent to check the other OAuth clients too, and it used the console's debug preview route to render the consent and result screens for Claude Code, Cursor, Codex, Claude Desktop and Gemini CLI in dark mode, then did one pass in light mode. That took 19 Playwright calls, with no errors, and produced eight screenshots.&lt;/p&gt;

&lt;p&gt;No test in the suite would have caught this, because no test checks that a logo is visible against its background. If I wrote one now, it would only protect against the last bug.&lt;/p&gt;

&lt;h3&gt;
  
  
  The OG image X wouldn't show
&lt;/h3&gt;

&lt;p&gt;The passkeys announcement showed a grey placeholder when shared on X. The agent found the cause quickly: the cover image was AVIF, and X only renders JPG, PNG, WEBP and GIF for cards. Every blog and changelog cover in the repo is AVIF, all 538 of them.&lt;/p&gt;

&lt;p&gt;The fix was a route that converts the cover to JPEG for &lt;code&gt;og:image&lt;/code&gt; and &lt;code&gt;twitter:image&lt;/code&gt; only. Then I asked for "pure reproduction and fix verification screenshots in pr desc". The agent built two small pages showing the share card before and after, resized the browser to the card's size and took a screenshot of each. Its first attempt failed because Playwright MCP blocks &lt;code&gt;file://&lt;/code&gt; URLs, so it served the pages over local HTTP instead.&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%2Fgygkdqwu8x5w29yiutc5.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%2Fgygkdqwu8x5w29yiutc5.webp" alt="Two panels. Before: production serves an AVIF twitter:image and X shows a grey placeholder. After: the branch serves a JPEG and the passkeys cover renders." width="800" height="356"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The two screenshots that went into the PR description&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Then the suite took over. The review bot flagged two problems, a type-baseline check broke because of the new route, and the four Console E2E jobs ran. Once all 20 checks were green, &lt;a href="https://github.com/appwrite/appwrite/pull/14622" rel="noopener noreferrer"&gt;#14622&lt;/a&gt; was ready. Both Playwrights were in that PR: one showed the reviewer what changed, the other showed that nothing else did.&lt;/p&gt;

&lt;h3&gt;
  
  
  Release QA for 2.3.0
&lt;/h3&gt;

&lt;p&gt;The biggest session of the month used 183 Playwright calls. I'd asked the agent to "do end to end testing of release notes, especially frontend side" before shipping Appwrite 2.3.0.&lt;/p&gt;

&lt;p&gt;It built the 2.3.0 image locally, installed 2.2.0 in a separate Docker Compose project so my dev data stayed safe, seeded it, and upgraded. &lt;code&gt;migrate&lt;/code&gt; failed: projects that already existed never got the new &lt;code&gt;pushLedger&lt;/code&gt; collection. After fixing that it found a second gap: existing topics were missing the new &lt;code&gt;qos&lt;/code&gt;, &lt;code&gt;expiry&lt;/code&gt; and &lt;code&gt;sequence&lt;/code&gt; fields. Both fixes shipped with regression tests and went into the release branch before anyone upgraded.&lt;/p&gt;

&lt;p&gt;To be fair, the browser didn't find the migration bug. &lt;code&gt;migrate&lt;/code&gt; exiting with code 1 did. What Playwright did was walk the console against the upgraded install and check each claim in the release notes, one screen at a time. Still, nothing in the test suite runs an upgrade. Release QA used to be manual QA. Now it's manual QA I hand to an agent.&lt;/p&gt;

&lt;p&gt;Where the browser does earn its keep is reproducing what users report. After 2.4.0, someone in the community said the upgrade wizard ignored their custom ports. I pasted the feedback and asked the agent to confirm each issue before fixing anything. It installed a stack on ports 8080 and 8443 with HTTPS on, opened the upgrade wizard, and screenshotted it offering 80, 443 and HTTPS off. Then it fixed the bug and took the same screenshot again. Nobody writes a test for this kind of bug until it bites someone.&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%2Fh8wfhzb7y5vttdlfce4a.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%2Fh8wfhzb7y5vttdlfce4a.webp" alt="Two upgrade wizards side by side. 2.4.0 shows HTTP port 80, HTTPS port 443 and HTTPS off. The fixed version keeps 8080, 8443 and HTTPS on." width="800" height="693"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Same install, two upgrade wizards (#14345)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Every PR gets a picture
&lt;/h2&gt;

&lt;p&gt;This is the habit I care most about. A reviewer can't run your branch, but they can look at it.&lt;/p&gt;

&lt;p&gt;The loop has four steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The agent checks the change with Playwright MCP.&lt;/li&gt;
&lt;li&gt;It saves each screenshot under a name that says what it shows: &lt;code&gt;passkeys-off.png&lt;/code&gt;, &lt;code&gt;passkeys-on.png&lt;/code&gt;, &lt;code&gt;cli-device-light.png&lt;/code&gt;. 317 of the month's 332 screenshots had a name like that.&lt;/li&gt;
&lt;li&gt;It writes the PR description with relative image links like &lt;code&gt;![Before](./before.png)&lt;/code&gt; and runs &lt;code&gt;gh pr create --attach&lt;/code&gt; or &lt;code&gt;gh pr edit --attach&lt;/code&gt;. Recent versions of &lt;code&gt;gh&lt;/code&gt; upload each file to GitHub and rewrite the link to point at the upload.&lt;/li&gt;
&lt;li&gt;It deletes &lt;code&gt;.playwright-mcp/&lt;/code&gt; and any loose PNGs so none of it gets committed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A good pair needs no explanation. When self-hosted consoles stopped offering OAuth and passkey sign-in, the PR showed the page before and after, and the reviewer could see the change in a second:&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%2F7sybcl4nrvf1qga5j50a.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%2F7sybcl4nrvf1qga5j50a.webp" alt="Appwrite console sign-in before and after. Before shows Google, GitHub, GitLab, Bitbucket and passkey buttons. After shows only email and password." width="799" height="293"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Self-hosted console sign-in, before and after (#14434)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Dark mode gets its own screenshot, because that's where things like the invisible logo hide:&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%2Fxw8hzxxv5z8x1pjf6s6a.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%2Fxw8hzxxv5z8x1pjf6s6a.webp" alt="Appwrite console sign-in in dark mode with a Sign in with a passkey button and a Last used badge on the Login button" width="800" height="538"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Passkey sign-in in dark mode, with the last-used badge (#14400)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I didn't start with &lt;code&gt;--attach&lt;/code&gt;. Over the month, screenshots reached my PRs three ways: committed to an orphan branch on my fork and linked through &lt;code&gt;raw.githubusercontent.com&lt;/code&gt;, committed to a repo as docs assets, and attached. A reviewer caught the problem with the first one: &lt;em&gt;"Screenshot evidence disappears after merge. Screenshots reference an assets branch that the instructions then delete."&lt;/em&gt; If the screenshots live on a branch you delete, the evidence goes with it. Attachments stay. 32 of the 53 PRs with screenshots now use them.&lt;/p&gt;

&lt;p&gt;And 53 out of 904 is the honest number. The rate depends on the repo. In the two public Appwrite repos where most of my UI work lands:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Repo&lt;/th&gt;
&lt;th&gt;PRs&lt;/th&gt;
&lt;th&gt;With screenshots&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;appwrite/vibes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;39&lt;/td&gt;
&lt;td&gt;11 (28%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;appwrite/appwrite&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;224&lt;/td&gt;
&lt;td&gt;25 (11%)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Backend-heavy repos sit far lower, which is partly fair, since there's often nothing to look at. But there's a less flattering reason too: I asked for screenshots by hand about 30 times this month ("add screenshots in pr desc", "before and after screenshot in pr desc", "show screenshots of ur testing in pr desc"). The habit only happened when I remembered to type it.&lt;/p&gt;

&lt;p&gt;So I made it a rule. This is now in my global &lt;code&gt;CLAUDE.md&lt;/code&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If a PR changes anything a reviewer can see (UI, rendered docs, emails, OG cards, dashboards), verify it with Playwright MCP and put screenshots in the PR description without being asked: before and after for fixes, light and dark for UI. Name each file for what it shows, reference it as &lt;code&gt;![alt](./name.png)&lt;/code&gt; in the body, and upload with &lt;code&gt;gh pr create --attach&lt;/code&gt; / &lt;code&gt;gh pr edit --attach&lt;/code&gt;. Never host screenshots on a branch that gets deleted, and never commit &lt;code&gt;.playwright-mcp/&lt;/code&gt; or stray PNGs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The other 553 calls
&lt;/h2&gt;

&lt;p&gt;These had nothing to do with UI work. Help Scout, the university's Brightspace, the Google Cloud and AWS consoles, an SMS provider's dashboard. These are tools I use daily that either have no API or none I have access to.&lt;/p&gt;

&lt;p&gt;The pattern is always the same: "use playwright to login to helpscout, i want you to check some tickets". I log in myself in the browser window, and the agent reads. The browser becomes an API for software that doesn't offer one. That's a different post, but it's why the server stays installed even in weeks when I'm not building UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it falls short
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;About 10% of calls fail.&lt;/strong&gt; 195 of 1,929 returned an error. The biggest causes were boring: the browser or tab had already closed (36), scripts trying to read files outside the sandbox (25), and timeouts waiting for a page or element (around 30). Four were "Browser is already in use", which usually means two parallel worktree sessions wanted the same browser profile.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phones barely exist.&lt;/strong&gt; There were 45 resizes this month and exactly one was a phone viewport, 390 by 844. Everything else was desktop. That's a blind spot, not a policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;file://&lt;/code&gt; is blocked.&lt;/strong&gt; Any quick HTML mockup has to be served over HTTP first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Screenshots land in the repo.&lt;/strong&gt; By default they go into &lt;code&gt;.playwright-mcp/&lt;/code&gt; at the repository root. Several sessions ended with the agent cleaning up after itself, and in at least one session the screenshots ended up in the main checkout instead of the worktree.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd tell someone starting
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Keep the test suite for anything that must never break. A screenshot is evidence, not a regression test.&lt;/li&gt;
&lt;li&gt;Use the MCP browser for the question the suite can't ask: does this look right?&lt;/li&gt;
&lt;li&gt;Name every screenshot and check both themes.&lt;/li&gt;
&lt;li&gt;Put before and after in the PR as attachments, not links to a branch.&lt;/li&gt;
&lt;li&gt;Write the rule down, so it doesn't depend on you remembering to type it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The suite catches what broke. The screenshots show the reviewer what's new.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.chiragaggarwal.tech/blog/my-agent-has-two-playwrights" rel="noopener noreferrer"&gt;chiragaggarwal.tech&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>playwright</category>
      <category>ai</category>
      <category>testing</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Seven months of coding agents: what $28,335 actually bought</title>
      <dc:creator>Chirag Aggarwal</dc:creator>
      <pubDate>Mon, 26 Oct 2026 12:36:00 +0000</pubDate>
      <link>https://dev.to/chiragagg5k/seven-months-of-coding-agents-what-28335-actually-bought-1ka3</link>
      <guid>https://dev.to/chiragagg5k/seven-months-of-coding-agents-what-28335-actually-bought-1ka3</guid>
      <description>&lt;p&gt;Seven months, $28,335, across five agent harnesses. For a lot of people running agents right now that's a heavy week.&lt;/p&gt;

&lt;p&gt;I publish that number on &lt;a href="https://www.chiragaggarwal.tech/trends" rel="noopener noreferrer"&gt;/trends&lt;/a&gt; and it updates itself, so I've had a while to look at it. What I expected to find was a story about cost. What's actually in the data is a story about scheduling, and the number that tells it is this one: &lt;strong&gt;0.7% of my agent's tool calls happened between midnight and six in the morning.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Claude Code and Codex arrive at 0.7% independently. Different vendors, different session formats, 1,432 sessions between them, and I only noticed the match because I ran the same script twice and thought I'd double-counted.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the bill
&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%2Fbj7fdalh97z49opx8htn.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%2Fbj7fdalh97z49opx8htn.png" alt="Agent tool calls by hour of day: almost nothing between 00:00 and 06:00, peaking at 16:00" width="799" height="358"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Interactive version on &lt;a href="https://www.chiragaggarwal.tech/blog/seven-months-of-coding-agents" rel="noopener noreferrer"&gt;the original post&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That's 201,473 tool calls bucketed by the hour I was awake for them. It looks like a workday because it is one. The peak is 16:00. The trough is 03:00, and the trough is empty.&lt;/p&gt;

&lt;p&gt;An agent that runs unattended doesn't produce this shape. If you leave loops running, your histogram flattens — the overnight band fills in, because that's the entire point of leaving them running. Mine has a hole in it, and the hole is where I was asleep.&lt;/p&gt;

&lt;p&gt;The weekly view says the same thing. Weekdays average $176 a day; weekends average $67. The spend tracks my calendar, not a scheduler's.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the spend actually bought
&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%2Fsyjxrx8eilqoebv5fsmk.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%2Fsyjxrx8eilqoebv5fsmk.png" alt="Agent spend by month: $21 in January, $240 February, $533 March, $3.9k April, $6.6k May, $5.3k June, $6.3k July, $5.5k August (partial)" width="800" height="325"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Interactive version on &lt;a href="https://www.chiragaggarwal.tech/blog/seven-months-of-coding-agents" rel="noopener noreferrer"&gt;the original post&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The curve is boring until April, where it goes up 7.4× in one month and then stays there. That's not a pricing change and it isn't a new model. April is when I stopped running one conversation at a time and started running agents in parallel git worktrees, which is the single largest change in how much I spend and the single largest change in how much lands.&lt;/p&gt;

&lt;p&gt;Underneath it: 9,350 commits across 145 repositories since January, concentrated in &lt;code&gt;sdk-generator&lt;/code&gt;, &lt;code&gt;cloud&lt;/code&gt; and &lt;code&gt;appwrite&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I want to be careful with that number, because it's the one most likely to be wrong in my favour. &lt;code&gt;sdk-generator&lt;/code&gt; emits bulk generated output, so its 1,933 commits are not 1,933 units of thought. And only 205 commits carry a &lt;code&gt;Co-authored-by&lt;/code&gt; trailer for an agent, which is not a measure of anything except how inconsistently I remember to keep the trailer. Take 9,350 as a statement of scale and nothing finer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The leash is fifteen actions long
&lt;/h2&gt;

&lt;p&gt;The interesting ratio isn't dollars per commit. It's how much rope the agent gets before I say something.&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;Claude Code&lt;/th&gt;
&lt;th&gt;Codex&lt;/th&gt;
&lt;th&gt;Combined&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sessions&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;1,232&lt;/td&gt;
&lt;td&gt;1,432&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Human turns&lt;/td&gt;
&lt;td&gt;3,016&lt;/td&gt;
&lt;td&gt;10,336&lt;/td&gt;
&lt;td&gt;13,352&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool calls&lt;/td&gt;
&lt;td&gt;29,438&lt;/td&gt;
&lt;td&gt;171,040&lt;/td&gt;
&lt;td&gt;200,478&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool calls per human turn&lt;/td&gt;
&lt;td&gt;9.8&lt;/td&gt;
&lt;td&gt;16.5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;15.0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Interrupts&lt;/td&gt;
&lt;td&gt;218&lt;/td&gt;
&lt;td&gt;1,829&lt;/td&gt;
&lt;td&gt;2,047&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Median human turns per session&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Median session length&lt;/td&gt;
&lt;td&gt;34 min&lt;/td&gt;
&lt;td&gt;16 min&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Sessions with at least one human turn, 12 January to 14 August 2026. Interrupts are escape presses in Claude Code and aborted turns in Codex.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Fifteen actions, then I say something. That is the whole working relationship in one number, and it has stayed roughly constant while the spend went up 300×. What April changed was how many leashes I hold at once, not how long any one of them is.&lt;/p&gt;

&lt;p&gt;The interrupt count is the part I didn't expect. 2,047 times I stopped a run in progress — a little over ten times per active day. I'd have guessed a tenth of that. Interrupting is so cheap that it stopped registering as an event, which is a strange thing to learn about your own habits from a log file.&lt;/p&gt;

&lt;p&gt;296 of 1,432 sessions were single-turn: ask, receive, done. That's 21%. The other 79% are conversations. The median session is four to seven human turns over sixteen to thirty-four minutes, which is not automation. It's pairing, with a partner who types faster than me and needs checking.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that argues against me
&lt;/h2&gt;

&lt;p&gt;I have around 130 skills installed. Across all my Claude Code transcripts, they've been invoked 52 times.&lt;/p&gt;

&lt;p&gt;Twenty-six of those are one skill, &lt;code&gt;release-sdk&lt;/code&gt;, which is a genuine repetitive chore. After that: &lt;code&gt;artifact-design&lt;/code&gt; six times, &lt;code&gt;dataviz&lt;/code&gt; four, &lt;code&gt;sentry-cli&lt;/code&gt; three, and then a tail of skills fired exactly once.&lt;/p&gt;

&lt;p&gt;The infrastructure behind those 130 is not casual. There's a harness-neutral root at &lt;code&gt;~/.agents/skills&lt;/code&gt; with a lock file that pins each skill to a GitHub commit, symlinked outward into Claude Code, Codex and Pi, with a config listing fifteen harnesses it knows how to install into. &lt;code&gt;~/.codex/AGENTS.md&lt;/code&gt;, &lt;code&gt;~/.gemini/GEMINI.md&lt;/code&gt; and &lt;code&gt;~/.config/opencode/AGENTS.md&lt;/code&gt; are byte-identical, all three md5 &lt;code&gt;295f62fc&lt;/code&gt;. I built a distribution system for my own instructions.&lt;/p&gt;

&lt;p&gt;And then I mostly don't invoke the things it distributes. I type instead.&lt;/p&gt;

&lt;p&gt;There are two readings. The generous one is that skills are a library, and a library you consult four times a year is still worth having. The honest one is that I enjoy building the workshop more than I use the tools in it, and that 130-installed-52-fired is a tooling habit rather than a workflow.&lt;/p&gt;

&lt;p&gt;Both are probably true. What neither reading supports is the idea that any of this productivity came from automation running without me.&lt;/p&gt;

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

&lt;p&gt;Here's the objection I can't fully answer: is 0.7% restraint, or just circumstance?&lt;/p&gt;

&lt;p&gt;Right now the marginal cost of running agents overnight is about as low as it will ever be. Everything is subsidised. I have the budget, five harnesses configured, worktree isolation already working, and a skills system that can install into fifteen agent runtimes. Every ingredient for a 24/7 loop is sitting on this machine, and the histogram still has a hole in it.&lt;/p&gt;

&lt;p&gt;So it's a choice, and it's worth being clear about what I'm buying with it. Fifteen tool calls is roughly how much work I can hold in my head and still meaningfully review. Past that I'm not reviewing, I'm accepting. The 2,047 interrupts are the mechanism: they only happen because I'm watching, and I'm only watching because the run is short enough to watch.&lt;/p&gt;

&lt;p&gt;The cost is real. There's work I could have shipped overnight and didn't. The trade is that I can still explain every one of those 9,350 commits, which matters more to me in a codebase I'll be on call for.&lt;/p&gt;

&lt;p&gt;$28,335 over seven months is a modest bill by current standards. I don't think that's because I was careful with money. I think it's because a leash fifteen actions long is inherently cheap, and the thing it's actually buying is that I never stopped being the one holding it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.chiragaggarwal.tech/blog/seven-months-of-coding-agents" rel="noopener noreferrer"&gt;chiragaggarwal.tech&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>claude</category>
      <category>codex</category>
    </item>
    <item>
      <title>MCP 2.0: the release that deleted the handshake</title>
      <dc:creator>Chirag Aggarwal</dc:creator>
      <pubDate>Mon, 26 Oct 2026 12:35:27 +0000</pubDate>
      <link>https://dev.to/chiragagg5k/mcp-20-the-release-that-deleted-the-handshake-18o6</link>
      <guid>https://dev.to/chiragagg5k/mcp-20-the-release-that-deleted-the-handshake-18o6</guid>
      <description>&lt;p&gt;MCP versions are dates. &lt;code&gt;2024-11-05&lt;/code&gt;, &lt;code&gt;2025-03-26&lt;/code&gt;, &lt;code&gt;2025-06-18&lt;/code&gt;, &lt;code&gt;2025-11-25&lt;/code&gt;, and now &lt;code&gt;2026-07-28&lt;/code&gt;. From the outside they all look like the same kind of event.&lt;/p&gt;

&lt;p&gt;The last one isn't. The working group called the release candidate "the largest revision of the protocol since launch," which usually means a long changelog. Here it means the connection model is gone. No &lt;code&gt;initialize&lt;/code&gt;. No session id. No held-open stream. If you'd asked me a year ago to describe MCP in one sentence, that sentence would be wrong now.&lt;/p&gt;

&lt;p&gt;Nothing you have deployed today stops working, which is exactly why it's easy to skim past.&lt;/p&gt;

&lt;h2&gt;
  
  
  The handshake era
&lt;/h2&gt;

&lt;p&gt;Every MCP revision up to and including &lt;code&gt;2025-11-25&lt;/code&gt; opened the same way. The client sends &lt;code&gt;initialize&lt;/code&gt; with its protocol version, its capabilities and its &lt;code&gt;clientInfo&lt;/code&gt;. The server answers with the version it picked, its own capabilities and its &lt;code&gt;serverInfo&lt;/code&gt;. The client sends &lt;code&gt;notifications/initialized&lt;/code&gt;. Only then is the connection allowed to carry anything useful.&lt;/p&gt;

&lt;p&gt;That exchange establishes a session, and everything after it is defined relative to that session. Over Streamable HTTP the server could mint an &lt;code&gt;Mcp-Session-Id&lt;/code&gt;, which the client then echoed on every request; &lt;code&gt;DELETE&lt;/code&gt; ended it, &lt;code&gt;Last-Event-ID&lt;/code&gt; resumed a dropped SSE stream.&lt;/p&gt;

&lt;p&gt;The design makes complete sense if you remember where MCP started. In November 2024 it was a thing you ran as a subprocess of your editor over stdio. One client, one server, one pipe, and the pipe's lifetime &lt;em&gt;is&lt;/em&gt; the session. Negotiating capabilities once at the start costs nothing when there is exactly one of everything.&lt;/p&gt;

&lt;p&gt;Streamable HTTP arrived in &lt;code&gt;2025-03-26&lt;/code&gt; and carried that model onto the network, where "the connection has a memory" stops being free.&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%2Fi0xd3t65johnyn1cco4t.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%2Fi0xd3t65johnyn1cco4t.png" alt="2025-11-25: the load balancer uses sticky routing, so every request goes to replica 1, which holds the session, and replicas 2 and 3 cannot answer" width="800" height="545"&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%2Fqq2b4em6lxvazmjyu06y.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%2Fqq2b4em6lxvazmjyu06y.png" alt="2026-07-28: the client sends only a bearer token, the load balancer round-robins, and all three replicas can answer anything" width="800" height="540"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Interactive version on &lt;a href="https://www.chiragaggarwal.tech/blog/mcp-2-0-the-release-that-deleted-the-handshake" rel="noopener noreferrer"&gt;the original post&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So you pick one: sticky routing, or a shared session store. Same problem, different bill. Every deploy drops live sessions. A crashed replica takes conversations with it. And since the session id tells a gateway nothing about what the request wants, the gateway has to open the JSON body to find out whether it's metering a &lt;code&gt;tools/list&lt;/code&gt; or a database drop.&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%2Fg2510h1wd201nlajo0ov.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%2Fg2510h1wd201nlajo0ov.png" alt="MCP revisions from 2024-11-05 (stdio, one subprocess) through 2025-03-26, 2025-06-18 and 2025-11-25 to 2026-07-28 (no session, no handshake)" width="800" height="157"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Four revisions of adding things to a connection, then one that removed the connection.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What 2026-07-28 actually does
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The handshake is gone.&lt;/strong&gt; No &lt;code&gt;initialize&lt;/code&gt;, no &lt;code&gt;initialized&lt;/code&gt;, no &lt;code&gt;Mcp-Session-Id&lt;/code&gt;. Everything that used to be negotiated once now travels on every request, in &lt;code&gt;_meta&lt;/code&gt;. The same tool call, before and after:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before: 2 round trips&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="nf"&gt;POST&lt;/span&gt; &lt;span class="nn"&gt;/mcp&lt;/span&gt; &lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/json&lt;/span&gt;
&lt;span class="na"&gt;{"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s"&gt;"2.0","id":1,"method":"initialize",&lt;/span&gt;
&lt;span class="s"&gt; "params":{"protocolVersion":"2025-11-25","capabilities":{},&lt;/span&gt;
&lt;span class="s"&gt;           "clientInfo":{"name":"my-app","version":"1.0"}}}&lt;/span&gt;

&lt;span class="err"&gt;POST&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;HTTP/&lt;/span&gt;&lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Mcp-Session-Id:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1868&lt;/span&gt;&lt;span class="err"&gt;a&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="err"&gt;c&lt;/span&gt;&lt;span class="mi"&gt;-3&lt;/span&gt;&lt;span class="err"&gt;a&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="err"&gt;f&lt;/span&gt;&lt;span class="mi"&gt;-4&lt;/span&gt;&lt;span class="err"&gt;f&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="err"&gt;b&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Content-Type:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;application/json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&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="nl"&gt;"id"&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="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"tools/call"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"params"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"search"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"arguments"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"q"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"otters"&lt;/span&gt;&lt;span class="p"&gt;}}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After: 1 request&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="nf"&gt;POST&lt;/span&gt; &lt;span class="nn"&gt;/mcp&lt;/span&gt; &lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt;
&lt;span class="na"&gt;MCP-Protocol-Version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-07-28&lt;/span&gt;
&lt;span class="na"&gt;Mcp-Method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tools/call&lt;/span&gt;
&lt;span class="na"&gt;Mcp-Name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;search&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/json&lt;/span&gt;
&lt;span class="na"&gt;{"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s"&gt;"2.0","id":1,"method":"tools/call",&lt;/span&gt;
&lt;span class="s"&gt; "params":{"name":"search","arguments":{"q":"otters"},&lt;/span&gt;
&lt;span class="s"&gt;           "_meta":{"io.modelcontextprotocol/clientInfo":{"name":"my-app","version":"1.0"}}}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second one is cold. That client has never spoken to this server, nothing was set up first, and nothing is kept afterward. The three new headers are there so a gateway can route and meter it without opening the body.&lt;/p&gt;

&lt;p&gt;Version negotiation stopped being a phase and became a field. The server accepts or rejects each request on its own, and if it can't speak the version it answers &lt;code&gt;UnsupportedProtocolVersionError&lt;/code&gt; listing the versions it can, so the client retries. There's a &lt;code&gt;server/discover&lt;/code&gt; method that returns supported versions and capabilities in one call, which servers &lt;strong&gt;MUST&lt;/strong&gt; implement and clients don't have to call. Discovery is a convenience now, not a precondition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Server-to-client calls stopped needing an open pipe.&lt;/strong&gt; That was the load-bearing dependency on sessions. A tool that needs to ask the user something used to do it over a stream held open for the length of the call, which pins the call to one process.&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%2F4yhvm089ua977ofoi9no.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%2F4yhvm089ua977ofoi9no.png" alt="A tool that has to stop and ask the user something. 2025-11-25 elicitation/create holds an SSE stream open on one pinned instance; 2026-07-28 multi round-trip returns InputRequiredResult and pins nothing" width="800" height="331"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Interactive version on &lt;a href="https://www.chiragaggarwal.tech/blog/mcp-2-0-the-release-that-deleted-the-handshake" rel="noopener noreferrer"&gt;the original post&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Under SEP-2322 the server returns an &lt;code&gt;InputRequiredResult&lt;/code&gt; and hangs up. The client answers later with &lt;code&gt;inputResponses&lt;/code&gt; and the &lt;code&gt;requestState&lt;/code&gt; it was handed, and any instance can take it from there. SEP-2260 tightens things further: a server may only start a request while it's actively processing one from the client. Between them they replace &lt;code&gt;elicitation/create&lt;/code&gt; and &lt;code&gt;sampling/createMessage&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;State moved into the open. The spec is blunt about where it went:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Servers that need to carry state across calls can do what HTTP APIs have always done: mint an explicit handle (a &lt;code&gt;basket_id&lt;/code&gt;, a &lt;code&gt;browser_id&lt;/code&gt;) from a tool and have the model pass it back as an ordinary argument on later calls.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The spec's own phrasing is that this "makes the state visible to the model rather than hidden away." A session was state the model never saw and could not recover once it was gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Infrastructure can read the traffic without parsing it.&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;SEP&lt;/th&gt;
&lt;th&gt;What it adds&lt;/th&gt;
&lt;th&gt;What it buys&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2243&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Mcp-Method&lt;/code&gt; and &lt;code&gt;Mcp-Name&lt;/code&gt; request headers&lt;/td&gt;
&lt;td&gt;Routing, metering and WAF rules with no body inspection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2549&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ttlMs&lt;/code&gt; and &lt;code&gt;cacheScope&lt;/code&gt; on list and read&lt;/td&gt;
&lt;td&gt;Clients know how long a tool list is good for, and who may share it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;414&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;traceparent&lt;/code&gt; / &lt;code&gt;tracestate&lt;/code&gt; / &lt;code&gt;baggage&lt;/code&gt; in &lt;code&gt;_meta&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;One trace across client, gateway and server, into any OTel backend&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A server rejects any request where the headers and the body disagree, which is what makes the headers safe to trust for anything that never looks inside.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this is 2.0 and not 1.4
&lt;/h2&gt;

&lt;p&gt;The protocol changes are the part you notice. The governance changes are the part that decides whether any of it survives the next revision, and there are three.&lt;/p&gt;

&lt;p&gt;Extensions became first class. They get reverse-DNS identifiers, negotiate through an &lt;code&gt;extensions&lt;/code&gt; map, version independently of the spec, and live in their own &lt;code&gt;ext-*&lt;/code&gt; repositories with their own maintainers. Tasks graduated out of the core and into &lt;code&gt;io.modelcontextprotocol/tasks&lt;/code&gt;. MCP Apps, which is server-rendered HTML in a sandboxed iframe talking back over the same JSON-RPC, ships as one too. The core stays small and the interesting parts move at their own speed.&lt;/p&gt;

&lt;p&gt;Features now have a lifecycle: Active, Deprecated, Removed, with a documented migration path and twelve months minimum before anything deprecated is eligible for removal. Three features entered it immediately.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Deprecated&lt;/th&gt;
&lt;th&gt;Use instead&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Roots&lt;/td&gt;
&lt;td&gt;Tool parameters, resource URIs, or server configuration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sampling&lt;/td&gt;
&lt;td&gt;Your LLM provider's API, directly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Logging&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;stderr&lt;/code&gt; on stdio, OpenTelemetry for anything structured&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All three still work. That's the point of writing the policy down.&lt;/p&gt;

&lt;p&gt;Conformance became a gate. A Standards Track SEP can't reach Final until matching scenarios land in the conformance suite, and official SDKs are scored against that suite under a tier system. All four Tier 1 SDKs (TypeScript, Python, Go, C#) shipped &lt;code&gt;2026-07-28&lt;/code&gt; support the day the spec did, with Rust in beta.&lt;/p&gt;

&lt;p&gt;The remaining breaking changes are small but worth knowing. Tool schemas moved to full JSON Schema 2020-12, so &lt;code&gt;oneOf&lt;/code&gt;, &lt;code&gt;$ref&lt;/code&gt; and conditionals are legal now, and the spec says out loud that you must not auto-dereference external &lt;code&gt;$ref&lt;/code&gt; URIs. &lt;code&gt;structuredContent&lt;/code&gt; takes any JSON value instead of only objects. The missing-resource error went from MCP's custom &lt;code&gt;-32002&lt;/code&gt; to the standard &lt;code&gt;-32602&lt;/code&gt;, which breaks anyone matching on that literal. Every &lt;code&gt;2025-11-25&lt;/code&gt; Tasks implementation needs migrating.&lt;/p&gt;
&lt;h2&gt;
  
  
  We had already bet on this
&lt;/h2&gt;

&lt;p&gt;I spent sixteen months building &lt;a href="https://github.com/appwrite/mcp" rel="noopener noreferrer"&gt;Appwrite's MCP server&lt;/a&gt;, and &lt;a href="https://www.chiragaggarwal.tech/blog/how-i-built-the-appwrite-mcp-server" rel="noopener noreferrer"&gt;most of that time went to authorization&lt;/a&gt;. When I got to the transport, the hosted server went out with one flag set:&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="nc"&gt;StreamableHTTPSessionManager&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json_response&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stateless&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No sessions. Every request carries a bearer token; verify it, build a client from it, serve the call, forget everything. I'd love to claim foresight here. The real reason is duller: OAuth had already made session state pointless, because the token identifies the user on every request, and I didn't want to run a Redis to hold something I had no use for.&lt;/p&gt;

&lt;p&gt;The spec shipped on July 28. We merged &lt;code&gt;2026-07-28&lt;/code&gt; support on July 29, in &lt;a href="https://github.com/appwrite/mcp/pull/89" rel="noopener noreferrer"&gt;PR #89&lt;/a&gt;, by moving from MCP Python SDK v1.28 to v2.0.0. The HTTP and OAuth surface didn't move at all. What made it a day rather than a quarter is that v2's session manager routes on the protocol version header, so one deployment serves both eras:&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;def&lt;/span&gt; &lt;span class="nf"&gt;_is_modern_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Scope&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Match the SDK&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s era-routing predicate: a present ``MCP-Protocol-Version``
    that is not a handshake-era revision goes to the modern path.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mcp-protocol-version&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="n"&gt;version&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;HANDSHAKE_PROTOCOL_VERSIONS&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Handshake versions, &lt;code&gt;2024-11-05&lt;/code&gt; through &lt;code&gt;2025-11-25&lt;/code&gt;, take the legacy path. Anything else takes the per-request one. Nobody's client broke.&lt;/p&gt;

&lt;p&gt;Three things did cost real work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tool errors.&lt;/strong&gt; v1 wrapped a raised exception into &lt;code&gt;CallToolResult(isError=true)&lt;/code&gt;. v2 turns an uncaught exception into a sanitized &lt;code&gt;-32603&lt;/code&gt;, so the model never learns why the call failed. Our &lt;code&gt;confirm_write&lt;/code&gt; refusals are only worth anything if the model can read them, so every handler now returns &lt;code&gt;is_error=True&lt;/code&gt; explicitly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CORS.&lt;/strong&gt; &lt;code&gt;Mcp-Method&lt;/code&gt; and &lt;code&gt;Mcp-Name&lt;/code&gt; ride on every modern request. If your allow-list doesn't name them, browser-based clients fail before they ever reach your auth code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metrics.&lt;/strong&gt; Modern clients never send &lt;code&gt;initialize&lt;/code&gt;, so our handshake counter would have quietly flatlined exactly as clients migrated, which is the worst possible time to lose a graph. It's keyed off activity windows now, so the timeseries stays comparable through the transition.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is protocol work. It's the ordinary cost of a dependency changing shape under you, and it only took a day because a decision made eighteen months earlier, for completely unrelated reasons, happened to line up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The good kind of boring
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;2026-07-28&lt;/code&gt; MCP server takes an HTTP request, reads some headers, and returns a response. That's the whole shape of it. You can put it behind a CDN, cache its list endpoints, and restart it in the middle of someone's conversation without telling anyone.&lt;/p&gt;

&lt;p&gt;It's a less interesting protocol than the one that launched in 2024. I think that's the point. The bidirectional stateful version was more fun to read about and considerably worse to operate, and operating it is what most of us actually do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;What it means here&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;handshake era&lt;/td&gt;
&lt;td&gt;Every MCP revision through &lt;code&gt;2025-11-25&lt;/code&gt;, where a connection began with &lt;code&gt;initialize&lt;/code&gt; and carried a session from then on.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Mcp-Session-Id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The header that identified that session. Removed in &lt;code&gt;2026-07-28&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;_meta&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A metadata object on every request. Now carries protocol version, client info and capabilities, one copy per request.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;server/discover&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A single call returning supported versions, capabilities and identity. Mandatory for servers, optional for clients.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sticky routing&lt;/td&gt;
&lt;td&gt;Making a load balancer send every request from one client to the same backend. The price of server-side session state.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;requestState&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The opaque blob a server hands back with an &lt;code&gt;InputRequiredResult&lt;/code&gt; so the follow-up can be processed by any instance.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SEP&lt;/td&gt;
&lt;td&gt;Specification Enhancement Proposal, MCP's change process. Standards Track SEPs now need conformance scenarios before reaching Final.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Streamable HTTP&lt;/td&gt;
&lt;td&gt;The HTTP transport introduced in &lt;code&gt;2025-03-26&lt;/code&gt;, replacing the original HTTP+SSE one.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CIMD&lt;/td&gt;
&lt;td&gt;Client ID Metadata Documents, the replacement for Dynamic Client Registration in OAuth flows.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The spec
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;a href="https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/" rel="noopener noreferrer"&gt;release candidate announcement&lt;/a&gt; and the &lt;a href="https://blog.modelcontextprotocol.io/posts/2026-07-28/" rel="noopener noreferrer"&gt;release itself&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/" rel="noopener noreferrer"&gt;&lt;code&gt;2026-07-28&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://modelcontextprotocol.io/specification/versioning" rel="noopener noreferrer"&gt;versioning and negotiation&lt;/a&gt;, and &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/server/discover" rel="noopener noreferrer"&gt;&lt;code&gt;server/discover&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://modelcontextprotocol.io/community/feature-lifecycle" rel="noopener noreferrer"&gt;feature lifecycle policy&lt;/a&gt; and the &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/deprecated" rel="noopener noreferrer"&gt;deprecated features registry&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://modelcontextprotocol.io/specification/2025-11-25/changelog" rel="noopener noreferrer"&gt;&lt;code&gt;2025-11-25&lt;/code&gt; changelog&lt;/a&gt;, for what the last handshake-era revision was still adding&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The code
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/appwrite/mcp" rel="noopener noreferrer"&gt;appwrite/mcp&lt;/a&gt;, and &lt;a href="https://github.com/appwrite/mcp/pull/89" rel="noopener noreferrer"&gt;PR #89&lt;/a&gt; — the dual-era migration&lt;/li&gt;
&lt;li&gt;The hosted server is at &lt;code&gt;https://mcp.appwrite.io/&lt;/code&gt;, or &lt;code&gt;claude mcp add --transport http appwrite https://mcp.appwrite.io/&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.chiragaggarwal.tech/blog/mcp-2-0-the-release-that-deleted-the-handshake" rel="noopener noreferrer"&gt;chiragaggarwal.tech&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>appwrite</category>
      <category>python</category>
    </item>
    <item>
      <title>Will MCP replace the CLI?</title>
      <dc:creator>Chirag Aggarwal</dc:creator>
      <pubDate>Mon, 26 Oct 2026 12:35:11 +0000</pubDate>
      <link>https://dev.to/chiragagg5k/will-mcp-replace-the-cli-4pe5</link>
      <guid>https://dev.to/chiragagg5k/will-mcp-replace-the-cli-4pe5</guid>
      <description>&lt;p&gt;Every developer tool now seems to need an MCP server. The growth is real: monthly downloads of the MCP TypeScript SDK went from 1.9 million in March 2025 to 191.9 million in July 2026.&lt;/p&gt;

&lt;p&gt;But the command line is not collapsing underneath it. Downloads of Commander, a widely used Node.js CLI framework, grew from 787 million to 1.93 billion over the same period.&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%2F9gwe6gnrga0zjvt7pql5.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%2F9gwe6gnrga0zjvt7pql5.png" alt="Monthly npm downloads, March 2025 to July 2026: MCP TypeScript SDK up 102x to 191.9M, Commander CLI framework up 2.5x to 1.93B" width="799" height="310"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Interactive version on &lt;a href="https://www.chiragaggarwal.tech/blog/will-mcp-replace-the-cli" rel="noopener noreferrer"&gt;the original post&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The numbers are imperfect because npm downloads include CI and transitive installs. Still, they look less like a replacement and more like a new layer being added.&lt;/p&gt;

&lt;h2&gt;
  
  
  MCP is not the GUI for agents
&lt;/h2&gt;

&lt;p&gt;A GUI and CLI are interfaces for humans. MCP is a contract between a model host and software. The host discovers tools, gives their schemas to the model, validates arguments, and sends the call.&lt;/p&gt;

&lt;p&gt;An agent can already use a CLI. It can run &lt;code&gt;--help&lt;/code&gt;, execute a command, inspect stdout, and retry. MCP makes that operation typed, discoverable, and easier for the host to police.&lt;/p&gt;

&lt;p&gt;That difference is enough to split the market:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;MCP wins when…&lt;/th&gt;
&lt;th&gt;CLI wins when…&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The product is remote&lt;/td&gt;
&lt;td&gt;The work is local&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OAuth should happen in the client&lt;/td&gt;
&lt;td&gt;Credentials already exist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Actions need a rendered confirmation&lt;/td&gt;
&lt;td&gt;Commands need to run unattended&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The agent must discover capabilities&lt;/td&gt;
&lt;td&gt;The workflow must be reproducible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Structured output matters more than UX&lt;/td&gt;
&lt;td&gt;Pipes, files, and exit codes matter&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a SaaS product, MCP is a much better front door. One URL and an OAuth flow can replace installing a package, setting environment variables, and teaching the model a command tree.&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%2Fkyrfn02oja4d35m37yu9.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%2Fkyrfn02oja4d35m37yu9.png" alt="One MCP URL, then OAuth, then discover tools, then confirm call" width="800" height="118"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;MCP turns installation, authentication, discovery, and approval into one client workflow.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For repository work, the calculation reverses. &lt;code&gt;git diff&lt;/code&gt;, &lt;code&gt;bun test&lt;/code&gt;, and &lt;code&gt;docker compose logs&lt;/code&gt; are already available, inspectable, and easy for a human to rerun. Wrapping each in a tool server often adds schema without adding a useful boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  The MCP trend nobody advertises
&lt;/h2&gt;

&lt;p&gt;Early MCP servers tried to expose everything. Clients and models pushed back. Cursor once sent only the first 40 tools to the agent; Windsurf refused servers that crossed its limit.&lt;/p&gt;

&lt;p&gt;The current direction is smaller surfaces with search behind them. Appwrite went from 981 methods to four visible tools. Stripe and Sentry also put larger catalogs behind search; GitHub groups its tools into sets.&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%2Fl8g4a92trpngc4zskv8h.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%2Fl8g4a92trpngc4zskv8h.png" alt="Tools the model actually sees in tools/list: GitHub 90, Supabase 29, Convex 12, Stripe 12 (whole API behind search), Sentry 9 (46 in catalog), Appwrite 4 (981 in catalog)" width="800" height="349"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Interactive version on &lt;a href="https://www.chiragaggarwal.tech/blog/will-mcp-replace-the-cli" rel="noopener noreferrer"&gt;the original post&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That is a real drawback, not an implementation detail. MCP tools consume context merely by existing. A CLI can have hundreds of subcommands without pasting every help page into the conversation.&lt;/p&gt;

&lt;p&gt;Uploads expose another mismatch. A hosted MCP server cannot read a path on the user's machine. Appwrite prefers a public URL, but also accepts a small image or deployment ZIP as Base64 inside the JSON tool call. Decoded inline content is capped at 10 MB, and Base64 makes the payload roughly one-third larger.&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%2Fn83aw7waj4pthc42b32g.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%2Fn83aw7waj4pthc42b32g.png" alt="Same upload, different transport. Hosted MCP: site.zip to base64 to JSON string to server decodes, with a 10 MB inline cap. CLI: site.zip read from ./site.zip and uploaded as bytes" width="800" height="285"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Interactive version on &lt;a href="https://www.chiragaggarwal.tech/blog/will-mcp-replace-the-cli" rel="noopener noreferrer"&gt;the original post&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The Base64 path works, but it is a hack around the transport. The CLI can open &lt;code&gt;./site.zip&lt;/code&gt; and upload its bytes directly without making the artifact public or passing it through the model's context.&lt;/p&gt;

&lt;p&gt;MCP also gives you another service to operate: authorization, deployment, observability, client compatibility, schema versioning, and a security policy for every mutation. OAuth proves who the user is. It does not prove the model understood what the user wanted before calling &lt;code&gt;delete_project&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The CLI's problems are older and easier to see: prose output, shell quoting, broad environment access, platform differences, and weak capability discovery. It is a worse interface for a model, surrounded by a better ecosystem for debugging and automation.&lt;/p&gt;

&lt;h2&gt;
  
  
  My prediction
&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%2Fd8hpmf4tzg2u9wi5rz1k.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%2Fd8hpmf4tzg2u9wi5rz1k.png" alt="An API as the source of truth, with a CLI for humans, CI and local work, and MCP for agents, discovery and OAuth. The agent host can also reach the CLI through a sandboxed shell" width="800" height="292"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;MCP does not need to replace the CLI to win. It only needs to become the agent-facing adapter.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;MCP will become the default way an agent first meets a remote product. The CLI will remain the deterministic path for local work, scripts, CI, and the moment an abstraction leaks.&lt;/p&gt;

&lt;p&gt;So I do not expect a replay of “GUI beats terminal.” That verdict never happened. Most people chose GUIs; developers kept terminals for the work where precision and composition mattered.&lt;/p&gt;

&lt;p&gt;MCP can win distribution without winning everything. The durable stack is likely an API underneath, a CLI for operators, and a narrow MCP surface for agents.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.chiragaggarwal.tech/blog/will-mcp-replace-the-cli" rel="noopener noreferrer"&gt;chiragaggarwal.tech&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>cli</category>
      <category>ai</category>
      <category>appwrite</category>
    </item>
    <item>
      <title>Appwrite MCP vs Vercel MCP: I benchmarked both</title>
      <dc:creator>Chirag Aggarwal</dc:creator>
      <pubDate>Wed, 19 Aug 2026 17:12:09 +0000</pubDate>
      <link>https://dev.to/chiragagg5k/appwrite-mcp-vs-vercel-mcp-i-benchmarked-both-3k3f</link>
      <guid>https://dev.to/chiragagg5k/appwrite-mcp-vs-vercel-mcp-i-benchmarked-both-3k3f</guid>
      <description>&lt;p&gt;An MCP server should be judged by what an agent can finish, not by how many tools sit in its catalog. So I pointed an agent at the hosted Appwrite and Vercel MCP servers and asked both to do the same job: deploy identical applications, inspect the builds, diagnose a deliberate failure, ship a second version, and recover from it.&lt;/p&gt;

&lt;p&gt;The two servers are built for different jobs, and the result splits along those lines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Appwrite reached much further into the backend&lt;/strong&gt; and was the only one of the two exposing rollback, deletion, and explicit write confirmation through MCP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Appwrite shipped a real bug into this run.&lt;/strong&gt; Its runtime log tool fetched the data successfully and then told the agent it had failed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vercel deployed faster and reported runtime failures better.&lt;/strong&gt; It reached &lt;code&gt;READY&lt;/code&gt; in about 2.1 seconds on the static app and its dedicated log tools filter properly.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Both hosted MCP servers ran over OAuth from the same Darwin 25.5 arm64 client. I threw away the first call to each tool so nobody paid for a cold start, then alternated between the two servers so neither got the benefit of going second. Anything that wrote data went into a throwaway project I could delete afterwards.&lt;/p&gt;

&lt;p&gt;Two test apps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A static site with identical &lt;code&gt;index.html&lt;/code&gt; and &lt;code&gt;marker.json&lt;/code&gt; files, deployed from the same source archive on both platforms.&lt;/li&gt;
&lt;li&gt;A Next.js 15.2.8 SSR app with deterministic build markers and controlled 200, 400, and 500 responses.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each agent had to discover its workspace, deploy both apps, inspect logs and analytics, recover from an intentional build failure, publish v2, attempt a rollback, and handle 50 requests at concurrency 10.&lt;/p&gt;

&lt;p&gt;Six stages, same order, both platforms:&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;Stage&lt;/th&gt;
&lt;th&gt;Fixture&lt;/th&gt;
&lt;th&gt;What separated them&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;Orient&lt;/td&gt;
&lt;td&gt;workspace + IDs&lt;/td&gt;
&lt;td&gt;Vercel ahead: 4.6 against 3.4 on discovery&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;02&lt;/td&gt;
&lt;td&gt;Deploy&lt;/td&gt;
&lt;td&gt;same source bytes&lt;/td&gt;
&lt;td&gt;Vercel ahead: READY in ~2.1s against ~27s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;03&lt;/td&gt;
&lt;td&gt;Break&lt;/td&gt;
&lt;td&gt;exit code 42&lt;/td&gt;
&lt;td&gt;Even, both diagnosed their own failure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;04&lt;/td&gt;
&lt;td&gt;Observe&lt;/td&gt;
&lt;td&gt;3×200 · 1×400 · 1×500&lt;/td&gt;
&lt;td&gt;Vercel ahead: filtered 12.3 KB against 115 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;05&lt;/td&gt;
&lt;td&gt;Recover&lt;/td&gt;
&lt;td&gt;v2 + rollback&lt;/td&gt;
&lt;td&gt;Appwrite ahead: rollback in 1.32s, not exposed at all on Vercel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;06&lt;/td&gt;
&lt;td&gt;Burst&lt;/td&gt;
&lt;td&gt;50 requests · c=10&lt;/td&gt;
&lt;td&gt;Even, both cleared it without errors&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  How I scored it
&lt;/h3&gt;

&lt;p&gt;Six things counted, correctness most of all: correctness and completeness at 30%, then result compactness, discoverability and call economy, median and p95 latency, and safety and lifecycle control at 15% each, and error recovery quality at 10%.&lt;/p&gt;

&lt;p&gt;The two combined totals landed close enough that the gap between them tells you nothing, and the per-category numbers behind them were never written up. The per-task scores from the read and observability phases are the ones worth putting on screen:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task, out of 5&lt;/th&gt;
&lt;th&gt;Appwrite&lt;/th&gt;
&lt;th&gt;Vercel&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Workspace and project discovery&lt;/td&gt;
&lt;td&gt;3.4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.6&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resource detail&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;List deployments&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.8&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deployment detail&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build logs&lt;/td&gt;
&lt;td&gt;4.0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.6&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runtime logs&lt;/td&gt;
&lt;td&gt;2.4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.2&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Usage and analytics&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.7&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Documentation search&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3.5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Appwrite wins five of the eight tasks, and the three it loses are the ones it loses badly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Router versus toolbox
&lt;/h2&gt;

&lt;p&gt;Vercel exposes &lt;strong&gt;33 direct tools&lt;/strong&gt; covering projects, deployments, logs, analytics, agent runs, collaboration, domains, purchases, and deployment protection. Once the agent has a team or project ID, it calls a tool named for the task.&lt;/p&gt;

&lt;p&gt;Appwrite exposes &lt;strong&gt;992 operations across 81 services&lt;/strong&gt; behind a handful of meta-tools. Instead of loading every schema into the model's context, the agent works through four calls:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;appwrite_get_context&lt;/code&gt; to find the account, organization, and project&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;appwrite_search_tools&lt;/code&gt; to find an operation&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;appwrite_call_tool&lt;/code&gt; to execute it&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;appwrite_search_docs&lt;/code&gt; to search current documentation&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%2Fib59p1z668gtr66w1yy4.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%2Fib59p1z668gtr66w1yy4.png" alt="One dot is one operation an agent can call: 33 named Vercel tools against 992 Appwrite operations, drawn to the same scale" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The router usually costs one extra call before an unfamiliar operation, and buys access to Auth, Databases, Storage, Messaging, Functions, Sites, usage, domains, and infrastructure from one interface. Vercel's toolbox is easier to navigate as long as the work stays inside deployment.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each server actually covers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Use case&lt;/th&gt;
&lt;th&gt;Appwrite MCP&lt;/th&gt;
&lt;th&gt;Vercel MCP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Static and SSR deployment&lt;/td&gt;
&lt;td&gt;Sites, deployments, activation&lt;/td&gt;
&lt;td&gt;One-call project creation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build and runtime logs&lt;/td&gt;
&lt;td&gt;Available, with defects&lt;/td&gt;
&lt;td&gt;Dedicated, filter-rich tools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth and user management&lt;/td&gt;
&lt;td&gt;Full backend management&lt;/td&gt;
&lt;td&gt;Not exposed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Databases and storage&lt;/td&gt;
&lt;td&gt;Full backend management&lt;/td&gt;
&lt;td&gt;Not exposed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Functions&lt;/td&gt;
&lt;td&gt;CRUD, deployments, executions, variables&lt;/td&gt;
&lt;td&gt;Runtime and deployment inspection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Usage and analytics&lt;/td&gt;
&lt;td&gt;Resource and compute metrics&lt;/td&gt;
&lt;td&gt;Visitors, pageviews, routes, events&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deployment protection&lt;/td&gt;
&lt;td&gt;Site and project controls&lt;/td&gt;
&lt;td&gt;Direct protection and bypass tools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Domains&lt;/td&gt;
&lt;td&gt;Domain catalog and DNS&lt;/td&gt;
&lt;td&gt;Availability, purchase, management&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent collaboration&lt;/td&gt;
&lt;td&gt;No direct equivalent&lt;/td&gt;
&lt;td&gt;Agent traces and toolbar threads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rollback and cleanup&lt;/td&gt;
&lt;td&gt;Activation, rollback, delete&lt;/td&gt;
&lt;td&gt;Not exposed through the tested tools&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Six strong rows each. "Not exposed" refers only to the MCP tool set I tested, not to what the platform can do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lifecycle control: Appwrite exposed more
&lt;/h2&gt;

&lt;p&gt;Getting the first URL live is the easy half. An operator also has to publish updates, switch versions, disable things, and clean up.&lt;/p&gt;

&lt;p&gt;Appwrite deployed v2 to the existing Site with an explicit build cache hit, switched the active deployment, and then rolled back to v1 in &lt;strong&gt;1.32 seconds&lt;/strong&gt;. The public health endpoint returned &lt;code&gt;version: "v1"&lt;/code&gt; immediately. Disabling the Site returned a 404 &lt;code&gt;router_deployment_not_found&lt;/code&gt;, and re-enabling restored v1 straight away.&lt;/p&gt;

&lt;p&gt;Vercel's inline deployment tool created a &lt;em&gt;new project&lt;/em&gt; for v2, which also meant it reported no previous build cache. Its tested MCP surface exposed no rollback, no redeploy to an existing project, no project deletion, and no general environment-variable management. Those capabilities exist elsewhere on the Vercel platform; the benchmark required MCP-only operation. The practical consequence is that both Vercel projects from this run are still live, because nothing in the 33 tools can delete them.&lt;/p&gt;

&lt;p&gt;Write safety differed too. Appwrite required &lt;code&gt;confirm_write=true&lt;/code&gt; before every mutation and refused the first unconfirmed create. Vercel's deploy tool had no equivalent parameter, though its commercial operations do separate quotes from purchases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deployment speed: Vercel was a bit faster
&lt;/h2&gt;

&lt;p&gt;Vercel accepted an inline file tree and had the static app ready in about &lt;strong&gt;2.1 seconds&lt;/strong&gt;, on one write. Appwrite needed two, create the Site then upload and activate a gzipped deployment, and took about &lt;strong&gt;27 seconds end to end&lt;/strong&gt;. On SSR the gap narrowed to &lt;strong&gt;39.1 seconds&lt;/strong&gt; against a &lt;strong&gt;91-second build&lt;/strong&gt;, roughly 2.3 times faster on reported build time alone.&lt;/p&gt;

&lt;p&gt;Both platforms rejected an earlier version of the SSR app, for different and legitimate reasons: Appwrite's adapter expected a &lt;code&gt;next.config.*&lt;/code&gt; file, and Vercel blocked Next.js 15.2.4 over a known vulnerability. Each agent diagnosed its own failure from the logs it was handed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observed latency
&lt;/h2&gt;

&lt;p&gt;Once the agent had picked a project, Appwrite was faster on every repeated read I measured, in the case of build logs by a factor of eight.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Repeated read&lt;/th&gt;
&lt;th&gt;Appwrite&lt;/th&gt;
&lt;th&gt;Vercel&lt;/th&gt;
&lt;th&gt;Runs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Resource detail&lt;/td&gt;
&lt;td&gt;416 ms&lt;/td&gt;
&lt;td&gt;891 ms&lt;/td&gt;
&lt;td&gt;3 + 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deployment list&lt;/td&gt;
&lt;td&gt;448 ms&lt;/td&gt;
&lt;td&gt;897 ms&lt;/td&gt;
&lt;td&gt;3 + 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deployment detail&lt;/td&gt;
&lt;td&gt;491 ms&lt;/td&gt;
&lt;td&gt;914 ms&lt;/td&gt;
&lt;td&gt;3 + 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build logs&lt;/td&gt;
&lt;td&gt;319 ms&lt;/td&gt;
&lt;td&gt;2,590 ms&lt;/td&gt;
&lt;td&gt;1 + 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Analytics&lt;/td&gt;
&lt;td&gt;416 ms&lt;/td&gt;
&lt;td&gt;915 ms&lt;/td&gt;
&lt;td&gt;1 + 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docs search&lt;/td&gt;
&lt;td&gt;525 ms&lt;/td&gt;
&lt;td&gt;1,464 ms&lt;/td&gt;
&lt;td&gt;3 + 3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Medians where three rounds landed, single calls where only one succeeded. Lower is better.&lt;/p&gt;

&lt;p&gt;Both servers cleared the bounded concurrency check without errors. Appwrite returned 50 of 50 in &lt;strong&gt;5.146 seconds&lt;/strong&gt; wall time, Vercel 50 of 50 in &lt;strong&gt;4.714 seconds&lt;/strong&gt;. Vercel was modestly faster, and the sample is far too small to support a broader performance claim.&lt;/p&gt;

&lt;p&gt;Compactness is where the read gap is real rather than marginal. Asking for 100 log entries returned roughly 115 KB from Appwrite for 96 events, which got context-truncated, against 12.3 KB of filtered events from Vercel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing between them
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Appwrite MCP&lt;/strong&gt; fits when the agent has to operate the backend as well as the deployment: users, data, files, messaging, functions, and Sites. It is also the stronger fit when you need explicit write confirmation, rollback, repeated deployment to one resource, and cleanup the agent can actually perform.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vercel MCP&lt;/strong&gt; fits when the job is deploying, inspecting, diagnosing, and analyzing Vercel-hosted applications. Its direct tools remove selection ambiguity, its builds were faster here, and its log filters make for a cleaner diagnostic loop.&lt;/p&gt;

&lt;p&gt;Worth saying plainly: both of these deploy sites well. Every app I handed to either server went live, and both agents read their own build failure and fixed it without help. Vercel got there faster, 2.1 seconds against 27 on the static app and roughly 2.3 times quicker on the SSR build, and its log responses came back small enough to actually read. Appwrite was slower to the first URL and reached much further once it arrived: rollback, deletion, write confirmation, and the rest of the 992 operations behind the router, none of which the 33 Vercel tools touch. If your work stops at deploying, that speed is the whole story. If it doesn't, one extra search call buys you a lot.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Test conditions&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Access.&lt;/strong&gt; Hosted MCP endpoints, authenticated over OAuth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client.&lt;/strong&gt; A single macOS arm64 machine, one continuous session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write phase.&lt;/strong&gt; Static deployment, SSR deployment, an induced build failure, a second version, and a rollback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Excluded.&lt;/strong&gt; No paid protection tier was enabled, and no purchase operation was executed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope.&lt;/strong&gt; Results describe the MCP surfaces as tested, not controlled infrastructure benchmarks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.chiragaggarwal.tech/blog/appwrite-mcp-vs-vercel-mcp" rel="noopener noreferrer"&gt;chiragaggarwal.tech&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>appwrite</category>
      <category>vercel</category>
    </item>
    <item>
      <title>How utopia-php/client keeps fixing our memory leaks</title>
      <dc:creator>Chirag Aggarwal</dc:creator>
      <pubDate>Wed, 05 Aug 2026 06:36:19 +0000</pubDate>
      <link>https://dev.to/chiragagg5k/how-utopia-phpclient-keeps-fixing-our-memory-leaks-57f3</link>
      <guid>https://dev.to/chiragagg5k/how-utopia-phpclient-keeps-fixing-our-memory-leaks-57f3</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published on &lt;a href="https://chirag.appwrite.network/blog/how-utopia-php-client-keeps-fixing-our-memory-leaks" rel="noopener noreferrer"&gt;my site&lt;/a&gt;, where the three diagrams are interactive.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One of our billing workers kept dying.&lt;/p&gt;

&lt;p&gt;It would sit flat for hours, then climb into its memory limit in about four minutes and get killed. Kubernetes restarted it, and later the same day it happened again. Six times a day, every day.&lt;/p&gt;

&lt;p&gt;The obvious first move is to look at what PHP is holding on to, and that's where it got strange. When PHP runs out of memory it tells you: a fatal error, a stack trace, the allocation that tipped it over. We had none of that. &lt;code&gt;memory_get_usage()&lt;/code&gt; reported a flat 6 MB the entire time, right up to the moment the process vanished.&lt;/p&gt;

&lt;p&gt;Those two facts together are the whole puzzle. Something was eating memory, and it wasn't the PHP heap. It was cURL, two sockets at a time, and the cause turned out to be a missing keyword.&lt;/p&gt;

&lt;p&gt;We've chased this same shape in more than one Appwrite service now, and the fix has been the same every time: stop building a new HTTP client for every call, and move the library onto &lt;code&gt;utopia-php/client&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually leaking
&lt;/h2&gt;

&lt;p&gt;The worker idles at ~330 MB against a 512Mi limit, which is the flat stretch below. Once a day the newly-due invoice batch runs, about a thousand invoices with several Stripe calls each, and it goes from comfortable to dead in roughly four minutes.&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%2Fjn9sb1wy1lit8h9q7qw2.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%2Fjn9sb1wy1lit8h9q7qw2.png" alt="Grafana panel titled Memory Usage, Requests and Limits by container, plotting percent of the container limit against time from 12:00 to 11:00 the next morning, with the vertical axis running from 45 to 100 percent. The series enters from the bottom just before 15:00 and holds between 57 and 62 percent for six hours with a repeating sawtooth. Just after 20:45 it turns almost vertical, passes 100 percent, and the series stops." width="800" height="474"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Memory as a percentage of the container limit; the axis starts at 45% because nothing happens below it. Six hours flat around 60%, then the batch starts and the line goes vertical: 332 MB, 374, 450, 533, against a 536 MB ceiling.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Luke traced it, and the code turned out to be completely unremarkable. &lt;code&gt;Pay\Adapter::call()&lt;/code&gt; built a &lt;code&gt;new Utopia\Fetch\Client&lt;/code&gt; for every request. In a one-shot FPM request that's fine; the process exits and the OS reclaims everything. In a long-lived worker it's a slow bleed, and the reason is a reference cycle that has nothing to do with the PHP heap.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;curl_setopt&lt;/code&gt; stores your write callback on the &lt;code&gt;CurlHandle&lt;/code&gt;. A closure declared inside an instance method captures &lt;code&gt;$this&lt;/code&gt;. So the handle holds the closure, the closure holds the adapter, and the adapter holds the handle. Nothing can be freed by refcounting, and &lt;code&gt;__destruct&lt;/code&gt;, with its &lt;code&gt;curl_close&lt;/code&gt;, only runs when PHP's cycle collector fires. By default that means after 10,000 cycle roots have piled up. Until then every request strands an open keep-alive 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%2Fdmvw4glhcw1y90hl65mu.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%2Fdmvw4glhcw1y90hl65mu.png" alt="Who is holding the cURL handle?" width="800" height="513"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Reduced down, that's this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Curl&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;?CurlHandle&lt;/span&gt; &lt;span class="nv"&gt;$handle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// the adapter holds the handle&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;// Nothing in here mentions $this. Declaring a closure inside an&lt;/span&gt;
        &lt;span class="c1"&gt;// instance method binds it anyway, and curl_setopt parks the&lt;/span&gt;
        &lt;span class="c1"&gt;// closure on the handle: adapter -&amp;gt; handle -&amp;gt; closure -&amp;gt; adapter.&lt;/span&gt;
        &lt;span class="nb"&gt;curl_setopt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="no"&gt;CURLOPT_WRITEFUNCTION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$body&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$chunk&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$chunk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is one keyword:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;static&lt;/code&gt; closure gets no &lt;code&gt;$this&lt;/code&gt;, so the last edge never forms and refcounting frees the handle as soon as the adapter goes out of scope. Neither callback used &lt;code&gt;$this&lt;/code&gt; in the first place, which is what makes the change safe and also what makes it so easy to miss.&lt;/p&gt;

&lt;p&gt;Two file descriptors and roughly a megabyte of native TLS buffers per request, none of it visible to &lt;code&gt;memory_get_usage()&lt;/code&gt;. Descriptors are capped per process too, so whichever ceiling you reach first decides how the thing dies: the kernel OOMKills you, or you start refusing connections with &lt;code&gt;Too many open files&lt;/code&gt;. I've written that exact closure before, probably more than once, and it would never have occurred to me that &lt;code&gt;static&lt;/code&gt; was load-bearing. Adding it to both callbacks in &lt;code&gt;utopia-php/fetch&lt;/code&gt; took 150 sequential requests from 309 fds and 183 MB RSS to 9 fds and 31 MB, flat.&lt;/p&gt;

&lt;p&gt;We shipped that and moved on, which was the wrong instinct. It fixed the symptom in one library and left three others still hand-rolling their own transport, each free to reinvent the same bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the library is
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;utopia-php/client&lt;/code&gt; is a PSR-18 HTTP client for PHP 8.5. It's about 2,400 lines including both transports, and it doesn't try to be clever:&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%2Fjbze24l6rjnwdkfa6gii.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%2Fjbze24l6rjnwdkfa6gii.png" alt="The layers of utopia-php/client" width="800" height="401"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every layer implements the same Adapter interface, so they stack in any order and a caller can replace any of them with a stub.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Utopia\Client&lt;/code&gt; itself does almost nothing at request time. It resolves the URI against a base, fills in default headers, optionally stamps a &lt;code&gt;traceparent&lt;/code&gt;, and hands the request to an adapter. Connection lifetime, TLS, timeouts and error classification all live in the adapter. Anything policy-shaped lives in a decorator.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CurlAdapter&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;withBaseUri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://api.stripe.com/v1'&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;withBearerAuth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$secret&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;withConnectionReuse&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;withTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sendRequest&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;Request\Factory&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;form&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Method&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'customers'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$email&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;h2&gt;
  
  
  Which specs it holds itself to
&lt;/h2&gt;

&lt;p&gt;Writing your own HTTP transport in five places means reading the specs badly in five places. Here's what the shared one is on the hook for.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Spec&lt;/th&gt;
&lt;th&gt;What it decides&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PSR-18&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;4xx&lt;/code&gt;/&lt;code&gt;5xx&lt;/code&gt; are responses; the two-branch exception contract&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PSR-7 / PSR-17&lt;/td&gt;
&lt;td&gt;Immutable messages and the factories that build them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RFC 9110&lt;/td&gt;
&lt;td&gt;Idempotency, &lt;code&gt;Retry-After&lt;/code&gt;, &lt;code&gt;Authorization&lt;/code&gt;, content negotiation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RFC 9112&lt;/td&gt;
&lt;td&gt;HTTP/1.1 framing, chunked bodies, status-line parsing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RFC 9113&lt;/td&gt;
&lt;td&gt;HTTP/2, which APNs requires and HTTP/1.1 can't satisfy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RFC 3986&lt;/td&gt;
&lt;td&gt;Base-URI resolution and dot-segment removal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RFC 6750 / 7617&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Bearer&lt;/code&gt; and &lt;code&gt;Basic&lt;/code&gt; credential formats&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RFC 7578 / 2046 / 2183&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;multipart/form-data&lt;/code&gt;, boundaries, &lt;code&gt;Content-Disposition&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RFC 1951 / 1952 / 7932 / 8878&lt;/td&gt;
&lt;td&gt;deflate, gzip, br, zstd content codings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RFC 8446&lt;/td&gt;
&lt;td&gt;TLS 1.3, and the floor you can pin below it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;W3C Trace Context&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;traceparent&lt;/code&gt; propagation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  PSR-18 decides what counts as an error
&lt;/h3&gt;

&lt;p&gt;The sentence that does the most work:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A Client MUST NOT treat a well-formed HTTP request or HTTP response as an error condition. For example, response status codes in the 400 and 500 range MUST NOT cause an exception and MUST be returned to the Calling Library as normal.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A &lt;code&gt;429&lt;/code&gt; is not a failure, it's an answer. So &lt;code&gt;sendRequest()&lt;/code&gt; returns it, and only genuine "there is no response" conditions throw. PSR-18 splits those into two branches: &lt;code&gt;RequestExceptionInterface&lt;/code&gt; for a malformed request or response, &lt;code&gt;NetworkExceptionInterface&lt;/code&gt; for a transport that failed. The type answers one question, which is the only one you have at the catch site. Would trying again help?&lt;/p&gt;

&lt;p&gt;The library's own hierarchy keeps that property all the way down.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ClientExceptionInterface
├── NetworkExceptionInterface — retrying may help
│   └── NetworkException
│       ├── DnsException, TimeoutException, ProtocolException, ProxyException
│       └── ConnectionException
│           └── TlsException
└── RequestExceptionInterface — retrying is pointless
    └── RequestException
        ├── InvalidUriException, InvalidResponseException
        └── AdapterPreconditionException, AdapterInitializationException
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each adapter maps its native error codes into that tree. The cURL adapter matches on &lt;code&gt;CURLE_*&lt;/code&gt; constants, guarded by &lt;code&gt;defined()&lt;/code&gt; so a libcurl build without HTTP/3 doesn't fatal at load.&lt;/p&gt;

&lt;p&gt;Another line from the spec you can find in the code almost verbatim:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If a Client chooses to decompress the message body then it MUST also remove the &lt;code&gt;Content-Encoding&lt;/code&gt; header and adjust the &lt;code&gt;Content-Length&lt;/code&gt; header.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Both adapters negotiate compression for you: the request advertises whatever codecs the transport can decode, and the response arrives as plaintext. Which means the &lt;code&gt;Content-Encoding: gzip&lt;/code&gt; and &lt;code&gt;Content-Length&lt;/code&gt; the server sent are now lies about the body you're holding, so the adapter drops both. Set your own &lt;code&gt;Accept-Encoding&lt;/code&gt; and it gets out of the way entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  Retrying, and why only some requests get to
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Retry&lt;/code&gt; is a decorator, and its default &lt;code&gt;Backoff&lt;/code&gt; strategy reads almost directly off RFC 9110. Only idempotent methods (§9.2.2) are retried, so a lost response can't turn into a double charge. Only transient outcomes are retried: a &lt;code&gt;NetworkExceptionInterface&lt;/code&gt;, or a &lt;code&gt;429&lt;/code&gt; / &lt;code&gt;502&lt;/code&gt; / &lt;code&gt;503&lt;/code&gt; / &lt;code&gt;504&lt;/code&gt;. A numeric &lt;code&gt;Retry-After&lt;/code&gt; (§10.2.3) beats the computed delay, because the server knows things the client doesn't.&lt;/p&gt;

&lt;p&gt;With no &lt;code&gt;Retry-After&lt;/code&gt;, the wait is exponential with full jitter: a value drawn uniformly from &lt;code&gt;[0, ceiling)&lt;/code&gt; rather than the ceiling itself.&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%2F5bzcfs64r1afkjlzgmuh.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%2F5bzcfs64r1afkjlzgmuh.png" alt="Exponential backoff with and without full jitter" width="800" height="625"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Our workers run as a fleet. A fleet that backs off deterministically comes back at a struggling upstream in lockstep and keeps it struggling.&lt;/p&gt;

&lt;p&gt;Every one of those decisions lives behind a single method, so a library with different rules writes its own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;Strategy&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;RequestInterface&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;?ResponseInterface&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;?ClientExceptionInterface&lt;/span&gt; &lt;span class="nv"&gt;$error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?float&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;utopia-php/storage&lt;/code&gt; does exactly that. S3 signals throttling in an XML body as often as in a status code, so &lt;code&gt;S3\RetryStrategy&lt;/code&gt; parses the body first and retries &lt;code&gt;SlowDown&lt;/code&gt;, &lt;code&gt;ServiceUnavailable&lt;/code&gt;, &lt;code&gt;Throttling&lt;/code&gt; and &lt;code&gt;RequestThrottled&lt;/code&gt;. It also refuses to retry a &lt;code&gt;503&lt;/code&gt; whose body parses cleanly into some other error code, which is the case a status-code-only rule gets wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;withBaseUri()&lt;/code&gt; is not string concatenation
&lt;/h3&gt;

&lt;p&gt;It looks like a convenience until you send &lt;code&gt;../v2/users&lt;/code&gt; and find out which one your client implements. This one does dot-segment removal, and only applies the base when the request URI is actually relative. An absolute URI passes through untouched.&lt;/p&gt;

&lt;h3&gt;
  
  
  Everything else is a header you'd otherwise hand-roll
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;withBasicAuth()&lt;/code&gt; is RFC 7617's &lt;code&gt;base64(user:pass)&lt;/code&gt;. &lt;code&gt;withBearerAuth()&lt;/code&gt; is RFC 6750's &lt;code&gt;Bearer &amp;lt;token&amp;gt;&lt;/code&gt;. &lt;code&gt;Part::file()&lt;/code&gt; builds an RFC 7578 part with its RFC 2183 &lt;code&gt;Content-Disposition&lt;/code&gt;. &lt;code&gt;withMinTlsVersion(Tls::V1_2)&lt;/code&gt; is an enum each adapter maps to &lt;code&gt;CURLOPT_SSLVERSION&lt;/code&gt; or Swoole's &lt;code&gt;ssl_protocols&lt;/code&gt;. &lt;code&gt;withTracePropagation()&lt;/code&gt; forwards the active &lt;code&gt;utopia-php/span&lt;/code&gt; trace as a &lt;code&gt;traceparent&lt;/code&gt;, and refuses to overwrite one that's already on the request.&lt;/p&gt;

&lt;p&gt;None of these are hard. They're just wrong in slightly different ways in every library that rolls its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four rules underneath
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Reuse over recreate
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;withConnectionReuse()&lt;/code&gt; keeps one connection alive per client and reuses it for every request to the same origin. &lt;code&gt;curl_reset()&lt;/code&gt; clears per-request options while preserving the handle's connection cache; the Swoole adapter keeps a kept-alive coroutine client keyed by origin. It's opt-in, on the theory that a client built for one call shouldn't sit on a socket. Any long-lived service wants it on.&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%2Fm85x0nkcg8f08xeckzqe.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%2Fm85x0nkcg8f08xeckzqe.png" alt="Six calls to the same origin, with and without connection reuse" width="800" height="266"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For Pay, that one change was the entire fix. Four hundred requests against a local echo server, before and after:&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;fds&lt;/th&gt;
&lt;th&gt;RSS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;client per call&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+800&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+21 MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;reused connection&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+48 kB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When you need concurrency rather than sequence, &lt;code&gt;Client\Pool&lt;/code&gt; borrows a client from a &lt;code&gt;utopia-php/pools&lt;/code&gt; pool per request and reclaims it afterwards, so N coroutines share a bounded set of connections instead of opening N of their own.&lt;/p&gt;

&lt;h3&gt;
  
  
  Every &lt;code&gt;with*()&lt;/code&gt; returns a clone
&lt;/h3&gt;

&lt;p&gt;In Swoole that's not a style preference. A shared client you can mutate is a cross-request bleed waiting to happen, and cloning means there's no way to reconfigure someone else's client from inside a request handler.&lt;/p&gt;

&lt;p&gt;A default is also only ever a default. &lt;code&gt;withHeaders()&lt;/code&gt; fills in a header the request doesn't already carry and nothing more, so a per-request &lt;code&gt;Content-Type&lt;/code&gt; beats the client-wide one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Policy lives in decorators
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Retry&lt;/code&gt; implements the same &lt;code&gt;Adapter&lt;/code&gt; interface it wraps, forwards every configuration helper inward, and overrides only &lt;code&gt;sendRequest()&lt;/code&gt; and &lt;code&gt;stream()&lt;/code&gt;. So retries, pooling and whatever you add stack in any order, and none of them turn into constructor flags on the transport.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;stream()&lt;/code&gt; override is my favorite detail in the library. It counts bytes handed to the sink, and once a single byte has been delivered it stops retrying, because replaying would duplicate data the caller already processed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bounded memory by default
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;stream()&lt;/code&gt; hands each chunk to a sink as it arrives, so SSE and LLM token streams cost the same memory as a ping. Uploads go the same way: cURL pulls the body through a read callback, &lt;code&gt;Part::file()&lt;/code&gt; reads lazily, and Swoole sends files with zero-copy &lt;code&gt;sendfile()&lt;/code&gt;. A seekable body gets rewound before each attempt, which is what makes a streamed upload safe to retry at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it ended up
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;Now&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utopia-php/pay&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;new Fetch\Client&lt;/code&gt; per call&lt;/td&gt;
&lt;td&gt;one injected client, &lt;code&gt;Adapter::call()&lt;/code&gt; deleted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utopia-php/messaging&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;raw &lt;code&gt;curl_*&lt;/code&gt; and &lt;code&gt;curl_multi&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;PSR-18, plus a Swoole pool for batched FCM/APNs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utopia-php/storage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ad-hoc HTTP in the S3 devices&lt;/td&gt;
&lt;td&gt;default client with a stall watchdog and &lt;code&gt;S3\RetryStrategy&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utopia-php/fastly&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;built on it from day one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;appwrite/appwrite&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;per-service HTTP wiring&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;jobs&lt;/code&gt; and &lt;code&gt;screenshots&lt;/code&gt; clients in the DI container&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Pay migration was net &lt;strong&gt;-55 lines&lt;/strong&gt;. &lt;code&gt;Adapter::call()&lt;/code&gt;, &lt;code&gt;handleError()&lt;/code&gt;, and nine &lt;code&gt;METHOD_*&lt;/code&gt; constants went with it — seven of the nine had no caller. A PSR-18 client and a PSR-17 factory already mean "build a request, send it"; the indirection was only ever a second vocabulary for HTTP.&lt;/p&gt;

&lt;p&gt;Messaging is the one worth reading if you're doing this yourself. It kept every adapter's public API identical while swapping &lt;code&gt;curl_multi&lt;/code&gt; for Swoole coroutines over a bounded pool, and added a &lt;code&gt;Closure(): ClientInterface&lt;/code&gt; factory so a caller can inject retries, a proxy, or a stub. The one constraint it documents loudly: your factory must produce a client that can negotiate HTTP/2, because APNs rejects HTTP/1.1 outright.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we grep for now
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Climbing RSS with a flat PHP heap. Count fds, not &lt;code&gt;memory_get_usage()&lt;/code&gt;. &lt;code&gt;ls /proc/&amp;lt;pid&amp;gt;/fd | wc -l&lt;/code&gt; in a loop finds this in five minutes.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;new Client()&lt;/code&gt; or &lt;code&gt;curl_init&lt;/code&gt; inside a request path. In a long-lived process a client is a resource with a lifetime, not a local variable.&lt;/li&gt;
&lt;li&gt;Non-static closures handed to &lt;code&gt;curl_setopt&lt;/code&gt;. If it doesn't touch &lt;code&gt;$this&lt;/code&gt;, make it &lt;code&gt;static&lt;/code&gt;. Costs nothing, and it's the whole bug.&lt;/li&gt;
&lt;li&gt;Any utopia library still carrying its own private HTTP stack. That's the rest of the work list.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/utopia-php/client" rel="noopener noreferrer"&gt;utopia-php/client&lt;/a&gt;, though development happens in the &lt;a href="https://github.com/utopia-php/monorepo/tree/main/packages/client" rel="noopener noreferrer"&gt;monorepo&lt;/a&gt;. &lt;a href="https://twitter.com/lukebsilver" rel="noopener noreferrer"&gt;@lukebsilver&lt;/a&gt; wrote it and did the Pay migration. I did messaging, which is how I ended up with opinions about APNs.&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%2Fg1brpy24ebo7frleo1r8.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%2Fg1brpy24ebo7frleo1r8.png" alt="Internal thread. Luke: the root cause is that Utopia\Pay\Adapter::call() does (new Client())-&gt;fetch(...) per Stripe API call, with a paste showing rss=63212kB fds=7 at start and rss=64696kB fds=47 at i=40, exactly +1 fd per fetch, RSS monotonic, PHP heap flat at 6.0MB throughout. Solution is to migrate to utopia-php/client. Reply: the astronaut meme, wait, solution is migrate to utopia-php/client? always has been." width="799" height="541"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Internal consensus, after the third identical incident. The paste is the diagnostic: fds climb one per call, RSS follows, the PHP heap never moves.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;What it means here&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;file descriptor (fd)&lt;/td&gt;
&lt;td&gt;The integer the kernel gives a process to refer to something it has open. Every live socket holds at least one, and each process has a cap (&lt;code&gt;ulimit -n&lt;/code&gt;).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RSS&lt;/td&gt;
&lt;td&gt;Resident set size: the physical memory a process actually occupies, native allocations included. This is what the kernel measures when it decides to kill you.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OOMKilled&lt;/td&gt;
&lt;td&gt;The kernel terminating a process for exceeding its memory limit. It happens outside PHP, so there's no fatal error and nothing in the logs.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PHP heap&lt;/td&gt;
&lt;td&gt;The pool the Zend allocator manages, which is what &lt;code&gt;memory_get_usage()&lt;/code&gt; reports and &lt;code&gt;memory_limit&lt;/code&gt; caps. cURL and OpenSSL allocate outside it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;reference cycle&lt;/td&gt;
&lt;td&gt;Objects holding each other so no refcount ever reaches zero. Only PHP's cycle collector can free them, and it runs on its own schedule.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;idempotent method&lt;/td&gt;
&lt;td&gt;A request that can be sent more than once without changing the outcome. &lt;code&gt;GET&lt;/code&gt;, &lt;code&gt;HEAD&lt;/code&gt;, &lt;code&gt;PUT&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt;, &lt;code&gt;OPTIONS&lt;/code&gt;, &lt;code&gt;TRACE&lt;/code&gt; qualify; &lt;code&gt;POST&lt;/code&gt; doesn't.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;full jitter&lt;/td&gt;
&lt;td&gt;Backoff where the wait is drawn uniformly from &lt;code&gt;[0, ceiling)&lt;/code&gt; instead of being the ceiling, so a fleet retrying together spreads out.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;coroutine&lt;/td&gt;
&lt;td&gt;Swoole's userland concurrency. One worker process interleaves many in-flight requests, which is why connection lifetime matters so much.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sink&lt;/td&gt;
&lt;td&gt;The callback &lt;code&gt;stream()&lt;/code&gt; hands each response chunk to as it arrives, instead of buffering the whole body.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;APNs / FCM&lt;/td&gt;
&lt;td&gt;Apple and Google's push notification services. APNs is the one that rejects HTTP/1.1.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The specs
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;HTTP semantics in &lt;a href="https://www.rfc-editor.org/rfc/rfc9110" rel="noopener noreferrer"&gt;RFC 9110&lt;/a&gt;, message framing in &lt;a href="https://www.rfc-editor.org/rfc/rfc9112" rel="noopener noreferrer"&gt;RFC 9112&lt;/a&gt;, HTTP/2 in &lt;a href="https://www.rfc-editor.org/rfc/rfc9113" rel="noopener noreferrer"&gt;RFC 9113&lt;/a&gt;, and &lt;code&gt;429&lt;/code&gt; in &lt;a href="https://www.rfc-editor.org/rfc/rfc6585" rel="noopener noreferrer"&gt;RFC 6585&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PHP-FIG: &lt;a href="https://www.php-fig.org/psr/psr-18/" rel="noopener noreferrer"&gt;PSR-18&lt;/a&gt; for clients, &lt;a href="https://www.php-fig.org/psr/psr-7/" rel="noopener noreferrer"&gt;PSR-7&lt;/a&gt; for messages, &lt;a href="https://www.php-fig.org/psr/psr-17/" rel="noopener noreferrer"&gt;PSR-17&lt;/a&gt; for factories&lt;/li&gt;
&lt;li&gt;URIs in &lt;a href="https://www.rfc-editor.org/rfc/rfc3986" rel="noopener noreferrer"&gt;RFC 3986&lt;/a&gt;; credentials in &lt;a href="https://www.rfc-editor.org/rfc/rfc6750" rel="noopener noreferrer"&gt;RFC 6750&lt;/a&gt; and &lt;a href="https://www.rfc-editor.org/rfc/rfc7617" rel="noopener noreferrer"&gt;RFC 7617&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Multipart in &lt;a href="https://www.rfc-editor.org/rfc/rfc7578" rel="noopener noreferrer"&gt;RFC 7578&lt;/a&gt;, building on &lt;a href="https://www.rfc-editor.org/rfc/rfc2046" rel="noopener noreferrer"&gt;RFC 2046&lt;/a&gt; and &lt;a href="https://www.rfc-editor.org/rfc/rfc2183" rel="noopener noreferrer"&gt;RFC 2183&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Content codings: &lt;a href="https://www.rfc-editor.org/rfc/rfc1951" rel="noopener noreferrer"&gt;deflate&lt;/a&gt;, &lt;a href="https://www.rfc-editor.org/rfc/rfc1952" rel="noopener noreferrer"&gt;gzip&lt;/a&gt;, &lt;a href="https://www.rfc-editor.org/rfc/rfc7932" rel="noopener noreferrer"&gt;br&lt;/a&gt;, &lt;a href="https://www.rfc-editor.org/rfc/rfc8878" rel="noopener noreferrer"&gt;zstd&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc8446" rel="noopener noreferrer"&gt;TLS 1.3&lt;/a&gt; and &lt;a href="https://www.w3.org/TR/trace-context/" rel="noopener noreferrer"&gt;W3C Trace Context&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The code
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/utopia-php/client" rel="noopener noreferrer"&gt;utopia-php/client&lt;/a&gt;, developed in &lt;a href="https://github.com/utopia-php/monorepo/tree/main/packages/client" rel="noopener noreferrer"&gt;the monorepo package&lt;/a&gt;, on top of &lt;a href="https://github.com/utopia-php/psr7" rel="noopener noreferrer"&gt;utopia-php/psr7&lt;/a&gt; and &lt;a href="https://github.com/utopia-php/pools" rel="noopener noreferrer"&gt;utopia-php/pools&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/utopia-php/fetch/issues/22" rel="noopener noreferrer"&gt;fetch#22&lt;/a&gt; — the reference cycle, with the fd and RSS measurements&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/utopia-php/pay/pull/32" rel="noopener noreferrer"&gt;pay#32&lt;/a&gt; — the migration that fixed the OOMKills, and the before/after numbers&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/utopia-php/messaging/pull/137" rel="noopener noreferrer"&gt;messaging#137&lt;/a&gt; — &lt;code&gt;curl_multi&lt;/code&gt; swapped for Swoole coroutines over a pool&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Background
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;AWS, &lt;a href="https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/" rel="noopener noreferrer"&gt;Exponential Backoff and Jitter&lt;/a&gt;, which is where the full-jitter formula comes from&lt;/li&gt;
&lt;li&gt;PHP manual, &lt;a href="https://www.php.net/manual/en/features.gc.collecting-cycles.php" rel="noopener noreferrer"&gt;Collecting Cycles&lt;/a&gt;, on when the collector actually runs&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Cross-posted from &lt;a href="https://chirag.appwrite.network/blog/how-utopia-php-client-keeps-fixing-our-memory-leaks" rel="noopener noreferrer"&gt;chirag.appwrite.network&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>debugging</category>
      <category>performance</category>
      <category>opensource</category>
    </item>
    <item>
      <title>How I built the Appwrite MCP server (and decided to hide most of its capabilities)</title>
      <dc:creator>Chirag Aggarwal</dc:creator>
      <pubDate>Tue, 04 Aug 2026 03:37:36 +0000</pubDate>
      <link>https://dev.to/chiragagg5k/how-i-built-the-appwrite-mcp-server-and-decided-to-hide-most-of-its-capabilities-4jm8</link>
      <guid>https://dev.to/chiragagg5k/how-i-built-the-appwrite-mcp-server-and-decided-to-hide-most-of-its-capabilities-4jm8</guid>
      <description>&lt;p&gt;When Anthropic introduced the Model Context Protocol on November 25, 2024, it got everyone's eyes on it, including Christy, who was Appwrite's Engineering Lead back then. I had just started my role as an "Engineering Intern" and had no idea what a whole new protocol meant, or why it was such a big deal.&lt;/p&gt;

&lt;p&gt;Looking at the surface, I wasn't entirely wrong. MCP is JSON-RPC with a schema and a handshake stapled on. What took us sixteen months was everything stapled 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%2F03ukr9g7u27dg1gmcfcy.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%2F03ukr9g7u27dg1gmcfcy.png" alt="Timeline: Nov 2024 MCP launches, Feb 2025 stdio ships, Mar 2025 Streamable HTTP, Apr 2026 981 tools become 4, Jun 2026 hosted merges, Jul 2026 shipped" width="800" height="100"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Streamable HTTP did not exist when MCP launched. It replaced HTTP+SSE in the 2025-03-26 revision.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The stdio years
&lt;/h2&gt;

&lt;p&gt;Christy had a working stdio server in the repo by February 26, 2025. We already had API keys, so the wiring was simple:&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 appwrite &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--env&lt;/span&gt; &lt;span class="nv"&gt;APPWRITE_PROJECT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;YOUR_PROJECT_ID&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--env&lt;/span&gt; &lt;span class="nv"&gt;APPWRITE_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;YOUR_API_KEY&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--env&lt;/span&gt; &lt;span class="nv"&gt;APPWRITE_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://cloud.appwrite.io/v1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--&lt;/span&gt; uvx mcp-server-appwrite
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An API key is scoped to exactly one project by design, so the ceiling was baked into the credential. Switching projects meant editing your editor config. Creating a project was impossible. So was anything at the organization level.&lt;/p&gt;

&lt;p&gt;The credential is the whole difference between the two transports, and everything hard about the hosted version follows from swapping it for a token that belongs to the user instead of the project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authorization ate the schedule
&lt;/h2&gt;

&lt;p&gt;By the spec, authorization is genuinely optional:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Authorization is OPTIONAL for MCP implementations. [...] Implementations using an HTTP-based transport SHOULD conform to this specification.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For a service where one tool call can drop a database, we weren't comfortable treating it as optional. If you use Auth0 or WorkOS, this is a config screen. Appwrite keeps everything in-house, so Matej built the authorization server itself, and I built the resource server plus whatever Cloud was still missing before real clients would work.&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%2Fdhn3rqujm9am5nolkzx6.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%2Fdhn3rqujm9am5nolkzx6.png" alt="Sequence diagram of the OAuth 2.1 handshake between the MCP client, mcp.appwrite.io and Appwrite Cloud" width="799" height="851"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Steps 2 through 6 are the part that makes "just paste this URL" work. Nothing is pre-provisioned.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Three RFCs carry that flow. Protected Resource Metadata (RFC 9728) is the only real MUST in the whole authorization spec:&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;"resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://mcp.appwrite.io/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"authorization_servers"&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;"https://cloud.appwrite.io/v1/oauth2/console"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scopes_supported"&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;"..."&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bearer_methods_supported"&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;"header"&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;Resource Indicators (RFC 8707) put our canonical URI into the token's &lt;code&gt;aud&lt;/code&gt;, so a token minted for another service can't be replayed against us. Dynamic Client Registration (RFC 7591) is what lets a client self-register. Add PKCE with &lt;code&gt;S256&lt;/code&gt;, RFC 8414 discovery, and you have the shape of it.&lt;/p&gt;

&lt;p&gt;The RFCs are documented. What isn't documented is that every client reads them differently, and you find out in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Raycast&lt;/strong&gt; was on the &lt;code&gt;2025-03-26&lt;/code&gt; authorization spec, which looks for &lt;code&gt;/.well-known/oauth-authorization-server&lt;/code&gt; instead of the protected-resource route. I only found it by putting a logging proxy in front of the server and watching what it actually asked for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude Code&lt;/strong&gt; re-authenticated every single run. It listens on an ephemeral loopback port, so the redirect URI never matched. OAuth's native-app BCP (RFC 8252 §7.3) says you must allow any port on &lt;code&gt;127.0.0.1&lt;/code&gt;. We weren't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Our own scope catalog&lt;/strong&gt; broke the flow. All ~118 granular scopes produced a &lt;code&gt;scope&lt;/code&gt; parameter of ~2,680 characters against a validator capped at 2,048. Nobody ever reached a consent screen.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One warning if you're about to build this: RFC 7591 went from SHOULD to MAY in &lt;code&gt;2025-11-25&lt;/code&gt; and is deprecated as of &lt;code&gt;2026-07-28&lt;/code&gt;, replaced by Client ID Metadata Documents. We shipped that too. This part of the spec is still moving.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sessions, and then no sessions
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;2025-06-18&lt;/code&gt; spec let a server hand out an &lt;code&gt;Mcp-Session-Id&lt;/code&gt; alongside the &lt;code&gt;InitializeResult&lt;/code&gt;, with &lt;code&gt;DELETE&lt;/code&gt; to terminate and &lt;code&gt;Last-Event-ID&lt;/code&gt; for resumability. We skipped all of it:&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="nc"&gt;StreamableHTTPSessionManager&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json_response&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stateless&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every request carries a bearer token. Verify it, build a client from it, serve the call. Nothing to store, nothing to lose on restart, nothing to make sticky across replicas.&lt;/p&gt;

&lt;p&gt;That turned out to be the right bet for a reason I can take no credit for. The &lt;code&gt;2026-07-28&lt;/code&gt; revision removed sessions from the protocol entirely. &lt;code&gt;Mcp-Session-Id&lt;/code&gt;, the &lt;code&gt;initialize&lt;/code&gt; handshake, the GET SSE stream: all gone. What we do carry is version negotiation, because you don't get to pick your clients' protocol version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Off-topic, but why not REST?
&lt;/h2&gt;

&lt;p&gt;Many intellectuals like myself must have wondered why MCP exists at all. Can't this be 100x simpler with, I don't know, REST?&lt;/p&gt;

&lt;p&gt;The model only knows its training data plus whatever you hand it at runtime. If it has never seen Appwrite, it will never guess this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST https://&amp;lt;REGION&amp;gt;.cloud.appwrite.io/v1/tablesdb
X-Appwrite-Project: &amp;lt;PROJECT_ID&amp;gt;
X-Appwrite-Key: &amp;lt;API_KEY&amp;gt;
Content-Type: application/json

{ "databaseId": "unique()", "name": "Production" }
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The endpoint, the header names, the fact that &lt;code&gt;unique()&lt;/code&gt; is a magic value. With MCP the same operation shows up self-describing:&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;"tables_db_create"&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;"Create a database in an Appwrite project"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"inputSchema"&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;"databaseId"&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;"string"&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="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;"string"&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;"required"&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;"databaseId"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"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="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;You can be happy knowing AI needs a lot more handholding than you do (for now).&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool choice
&lt;/h2&gt;

&lt;p&gt;Every MCP server I looked at ships a small, curated set. Appwrite generates one tool per SDK method, which lands at 981 methods across 38 services.&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%2Fgc37pcad0p8vyuywgkpy.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%2Fgc37pcad0p8vyuywgkpy.png" alt="Bar chart of tools exposed in tools/list per MCP server: GitHub 90, Supabase 29, Convex 12, Stripe 12, Sentry 9, Appwrite 4 over a 981-method catalog" width="800" height="317"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Counts as of August 2026. GitHub's 90 are grouped into 22 toolsets with 5 on by default.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;There's no version of "expose them all" that works, for two unrelated reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The clients won't take them.&lt;/strong&gt; In early 2025 Cursor documented that it "will only send the first 40 tools to the Agent" and truncated silently. Windsurf refused outright above 50.&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%2Ffq3vrscwxuorh8jm9ilx.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%2Ffq3vrscwxuorh8jm9ilx.png" alt="Discord report from liviu74: Windsurf error " width="800" height="190"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Discord, liviu74, Mar 14 2025. Windsurf refused it; Cursor accepted it and silently dropped tools 41 onward.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That was with per-service flags already in place, which is the part that stings. A community user opened &lt;a href="https://github.com/appwrite/mcp/issues/17" rel="noopener noreferrer"&gt;issue #17&lt;/a&gt;, "Please reduce the number of tools":&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Cursor has 40 MCP tools limit to use, but Appwrite solely has 195 tools, so it cannot be used with other tools nor even all of Appwrite tools.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I pointed out you could narrow it with &lt;code&gt;--databases&lt;/code&gt;. The reply:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;That's quite non-sense. Then do I have to edit MCP parameter settings for each time whenever I do another jobs...? And anyway, &lt;code&gt;--databases&lt;/code&gt; solely has 42 tools, which already exceeds Cursor's recommended limit (40).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Quality falls off well below the caps.&lt;/strong&gt; The numbers converge from unrelated directions. Anthropic puts degradation at "once you exceed 30-50 available tools". OpenAI says "fewer than 20 functions at the start of a turn". Block's Goose recommends 50 or fewer. And the fix measures well: Anthropic's &lt;a href="https://www.anthropic.com/engineering/advanced-tool-use" rel="noopener noreferrer"&gt;advanced tool use&lt;/a&gt; work takes Opus 4 from 49% to 74% on MCP tool-use evals with a search tool enabled, and Opus 4.5 from 79.5% to 88.1%, with 85% fewer tokens on definitions. &lt;a href="https://arxiv.org/abs/2505.03275" rel="noopener noreferrer"&gt;RAG-MCP&lt;/a&gt; more than triples selection accuracy (43.13% against 13.62%).&lt;/p&gt;

&lt;p&gt;The caveat, because it cuts against me: &lt;a href="https://arxiv.org/abs/2508.16260" rel="noopener noreferrer"&gt;MCPVerse&lt;/a&gt; found some agentic models handle big action spaces fine. Claude-4-Sonnet scored 62.3 with an oracle tool set and 62.4 with ~220 tools. Big catalogs aren't fatal. They're a tax you're paying for nothing when the agent needs three tools out of 981.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four tools over 981
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;appwrite_get_context&lt;/code&gt; answers where you are and which projects you can see&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;appwrite_search_tools&lt;/code&gt; searches the hidden catalog in natural language&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;appwrite_call_tool&lt;/code&gt; calls one of them by name&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;appwrite_search_docs&lt;/code&gt; searches the Appwrite docs semantically&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Search narrows at request time, which is why the per-service flags could be deleted entirely. Mutations require &lt;code&gt;confirm_write: true&lt;/code&gt;, and results too large for the conversation become MCP resources instead.&lt;/p&gt;

&lt;p&gt;The scoring behind &lt;code&gt;appwrite_search_tools&lt;/code&gt; is deliberately dumb: token and substring matching against the tool name, description, service and resource, a bonus when the query's inferred verb matches the tool's, a penalty when it doesn't. No embeddings, no index to rebuild, no inference call in the hot path.&lt;/p&gt;

&lt;p&gt;Here's what a client sees:&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%2Fd1vhnchw9xkmp9tj4h47.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%2Fd1vhnchw9xkmp9tj4h47.png" alt="Cursor MCP settings showing the appwrite server connected with 4 tools and 1 resource enabled" width="800" height="223"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;981 methods behind 4 tools and 1 resource. The "Logout" link is the OAuth session.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What made me stop second-guessing the design is that we weren't alone. Stripe put its whole API behind &lt;code&gt;stripe_api_search&lt;/code&gt;. Sentry exposes 9 of 46 through &lt;code&gt;search_sentry_tools&lt;/code&gt;. GitHub removed its dynamic toolset tools and looks to be building a search replacement. Three companies with no reason to coordinate landed on search-then-execute in the same window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it landed
&lt;/h2&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 appwrite https://mcp.appwrite.io/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No API key, no project ID, no config editing to switch projects. Projects and organizations are parameters on the call now instead of properties of the credential.&lt;/p&gt;

&lt;p&gt;stdio didn't go away. I removed it in the hosted refactor and put it back two days later, because self-hosted users need it. It runs on a project API key and gets 647 of the 981 methods, since a project key can't reach console-level operations anyway.&lt;/p&gt;

&lt;p&gt;Behind that URL there's also OpenTelemetry, Sentry, Grafana dashboards, and region routing so a project in another Cloud region doesn't return &lt;code&gt;general_access_forbidden&lt;/code&gt;. You end up operating a service, not publishing a package. That's the part I underestimated most.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell anyone building one
&lt;/h2&gt;

&lt;p&gt;The transport is not where the time goes. The authorization spec and everything it pulls in is where the months disappear.&lt;/p&gt;

&lt;p&gt;Test against real clients early and expect them to disagree. A logging proxy in front of your server was worth more to me than another pass through the docs.&lt;/p&gt;

&lt;p&gt;Assume the spec moves under you. Between starting and shipping, sessions were removed, RFC 7591 was deprecated, and a stateless revision landed. Anthropic donated MCP to the Agentic AI Foundation in December 2025, so it isn't even one vendor's project anymore.&lt;/p&gt;

&lt;p&gt;And don't hand your API surface over as your tool surface. The architecture this server has today came out of a bug report from a user who was annoyed with us, which I think is the correct way for this to have gone.&lt;/p&gt;

&lt;p&gt;The server is open source at &lt;a href="https://github.com/appwrite/mcp" rel="noopener noreferrer"&gt;github.com/appwrite/mcp&lt;/a&gt;, and the hosted one is at &lt;code&gt;https://mcp.appwrite.io/&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>oauth</category>
      <category>appwrite</category>
    </item>
    <item>
      <title>Managing multiple docker hub accounts using docker-use</title>
      <dc:creator>Chirag Aggarwal</dc:creator>
      <pubDate>Mon, 25 May 2026 05:08:01 +0000</pubDate>
      <link>https://dev.to/chiragagg5k/managing-multiple-docker-hub-accounts-using-docker-use-b6p</link>
      <guid>https://dev.to/chiragagg5k/managing-multiple-docker-hub-accounts-using-docker-use-b6p</guid>
      <description>&lt;p&gt;Most of the time I'm signed into my work Docker Hub account, and that's fine. Almost everything I build ends up there. Then one weekend I was playing around with &lt;a href="https://github.com/open-runtimes/orchestrator" rel="noopener noreferrer"&gt;openruntimes/orchestrator&lt;/a&gt;, an orchestration service for running containerized workloads with callbacks. I had a PR open with some small tweaks, and I wanted to publish release-candidate images so I could pull them down and actually test end-to-end.&lt;/p&gt;

&lt;p&gt;I didn't want those RC images sitting in my work namespace. So for the first time, I actually needed to switch Docker Hub accounts on my machine, and there isn't a good way to do it. The official answer is &lt;code&gt;docker logout&lt;/code&gt;, then &lt;code&gt;docker login&lt;/code&gt; again. Docker does respect a &lt;code&gt;DOCKER_CONFIG&lt;/code&gt; environment variable that points at a config directory, but you still have to set it yourself every time.&lt;/p&gt;

&lt;p&gt;A few years ago I'd have spent the afternoon hunting for someone else's tool. In the age of AI, you can just build the one you wished existed. So I did.&lt;/p&gt;

&lt;p&gt;The result is &lt;a href="https://github.com/chiragagg5k/docker-use" rel="noopener noreferrer"&gt;&lt;code&gt;docker-use&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea
&lt;/h2&gt;

&lt;p&gt;The fix is small. Docker already respects the &lt;code&gt;DOCKER_CONFIG&lt;/code&gt; environment variable, and whatever directory it points at is treated as the active config. So the whole mechanism is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Give every account its own config directory.&lt;/li&gt;
&lt;li&gt;Let the shell flip &lt;code&gt;DOCKER_CONFIG&lt;/code&gt; between them by name.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No patches to Docker, no daemon involvement, no new credential store. A directory per account, plus a shell function to switch between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# one-time per account&lt;/span&gt;
docker-use add appwrite &lt;span class="nt"&gt;-u&lt;/span&gt; my-appwrite-username
docker-use add personal &lt;span class="nt"&gt;-u&lt;/span&gt; chiragagg5k

&lt;span class="c"&gt;# day-to-day&lt;/span&gt;
docker-use appwrite

docker-use personal
docker push chiragagg5k/job-sidecar:temp-appwrite-pr-4f081ed-20260521-r2

docker-use &lt;span class="nb"&gt;whoami&lt;/span&gt;   &lt;span class="c"&gt;# which one am I on right now?&lt;/span&gt;
docker-use list     &lt;span class="c"&gt;# all accounts&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;add&lt;/code&gt; shells out to a normal &lt;code&gt;docker login&lt;/code&gt;, so 2FA and access tokens behave the way you already expect.&lt;/p&gt;

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

&lt;p&gt;There are three pieces.&lt;/p&gt;

&lt;p&gt;Each account lives in its own config directory, at &lt;code&gt;~/.docker-accounts/&amp;lt;name&amp;gt;/config.json&lt;/code&gt;. Names are validated against &lt;code&gt;^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$&lt;/code&gt; so they can't escape the directory or surprise the shell.&lt;/p&gt;

&lt;p&gt;The trickier piece is a shell wrapper that mutates the parent shell. A child process can't change its parent's environment variables, so the binary on its own can't make &lt;code&gt;DOCKER_CONFIG&lt;/code&gt; stick in your shell. The workaround is a shell function, generated by &lt;code&gt;docker-use init zsh&lt;/code&gt;, that you source in your rc file:&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;eval&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker-use init zsh&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The wrapper calls the binary, reads the printed config path, and assigns it to &lt;code&gt;DOCKER_CONFIG&lt;/code&gt; in the current shell. It's a plain assignment, not an eval on the path itself, so a weird config name can't smuggle anything into your shell. &lt;code&gt;zsh&lt;/code&gt;, &lt;code&gt;bash&lt;/code&gt;, and &lt;code&gt;fish&lt;/code&gt; all work.&lt;/p&gt;

&lt;p&gt;The last piece is keeping Docker's credential helpers in place. When &lt;code&gt;docker login&lt;/code&gt; finishes, the resulting &lt;code&gt;config.json&lt;/code&gt; often contains &lt;code&gt;"credsStore": "osxkeychain"&lt;/code&gt; (or &lt;code&gt;secretservice&lt;/code&gt; on Linux). If you wipe that field, Docker Desktop's keychain integration breaks and the next push prompts you for a password. So during &lt;code&gt;add&lt;/code&gt;, &lt;code&gt;docker-use&lt;/code&gt; reads the config Docker just wrote and preserves the credential helper settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things I'd flag if you read the source
&lt;/h2&gt;

&lt;p&gt;A few decisions that weren't obvious going in.&lt;/p&gt;

&lt;p&gt;The shell wrapper assigns the config path directly instead of eval-ing whatever the binary prints. Eval-ing user-controllable output tends to age badly.&lt;/p&gt;

&lt;p&gt;Account names get validated before any filesystem operation runs. &lt;code&gt;docker-use add ../etc -u …&lt;/code&gt; is not a debugging session I want to have.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;remove&lt;/code&gt; asks for confirmation before deleting. It's a destructive action on credentials, so the extra keystroke is worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew tap chiragagg5k/tools
brew &lt;span class="nb"&gt;install &lt;/span&gt;docker-use
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then drop this into your &lt;code&gt;~/.zshrc&lt;/code&gt; (or &lt;code&gt;~/.bashrc&lt;/code&gt;, or &lt;code&gt;~/.config/fish/config.fish&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker-use init zsh&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Binaries for other platforms are on the &lt;a href="https://github.com/chiragagg5k/docker-use/releases" rel="noopener noreferrer"&gt;releases page&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;If you end up using it, I'd love to hear what works and what doesn't. Open an issue or reach out directly.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>How we solved logging at Appwrite</title>
      <dc:creator>Chirag Aggarwal</dc:creator>
      <pubDate>Sun, 19 Apr 2026 11:56:39 +0000</pubDate>
      <link>https://dev.to/chiragagg5k/how-we-solved-logging-at-appwrite-5612</link>
      <guid>https://dev.to/chiragagg5k/how-we-solved-logging-at-appwrite-5612</guid>
      <description>&lt;p&gt;A few weeks back I came across a post by &lt;a href="https://twitter.com/boristane" rel="noopener noreferrer"&gt;@boristane&lt;/a&gt; sharing a website he made, &lt;a href="https://loggingsucks.com" rel="noopener noreferrer"&gt;loggingsucks.com&lt;/a&gt;. It caught my eye because it had been shared by my favorite tech YouTuber, &lt;a href="https://twitter.com/theo" rel="noopener noreferrer"&gt;@theo&lt;/a&gt;. Like most people, I was really inspired by the article and shared it with my team. &lt;a href="https://twitter.com/lukebsilver" rel="noopener noreferrer"&gt;@lukebsilver&lt;/a&gt;, Appwrite's Engineering Lead, was also inspired by it and decided to work on a new PHP library, &lt;code&gt;utopia-php/span&lt;/code&gt;, to fix logging throughout the Appwrite codebase.&lt;/p&gt;

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

&lt;p&gt;Before this, Appwrite used a combination of two different libraries targeting logging in two different areas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;utopia-php/console&lt;/code&gt; — a very simple wrapper library around stdout logging using functions like &lt;code&gt;Console::success()&lt;/code&gt;, &lt;code&gt;Console::error()&lt;/code&gt;, etc.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;utopia-php/logger&lt;/code&gt; — an adapter-based library to push error logs to monitoring systems like Sentry, AppSignal, Raygun, etc.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Combined, these libraries served their purpose for a long time, but we often ran into problems when debugging production issues, the same ones the original article discusses in detail. I'd highly recommend going through &lt;a href="https://loggingsucks.com" rel="noopener noreferrer"&gt;that article&lt;/a&gt; first so I don't repeat it all here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our solution
&lt;/h2&gt;

&lt;p&gt;Funnily enough, the first tricky problem was deciding on a name. "Logger" was already taken, so we had to be creative. The word "Span" captured exactly what we were trying to solve: a fundamental unit of work with a named, timed operation alongside various attributes, errors, trace IDs, etc.&lt;/p&gt;

&lt;p&gt;The first step was to move away from simple log lines to &lt;strong&gt;structured logging&lt;/strong&gt;. Span enforces this by only exposing a single primary method, &lt;code&gt;add()&lt;/code&gt;, which accepts a key-value pair.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Console&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Deleting project &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$project&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getId&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (type=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$type&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, region=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$project&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'region'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Span&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'project.id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$project&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getId&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="nc"&gt;Span&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'project.type'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$type&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;Span&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'project.region'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$project&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'region'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This massively improved the queryability of our logs — one of the things we struggled with most when going through logs in production.&lt;/p&gt;

&lt;p&gt;We also wanted the library to be extremely simple to use. Earlier, with "logger", we had to hop through various dependency injection loops just to use it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;Message&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;Document&lt;/span&gt; &lt;span class="nv"&gt;$project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;Log&lt;/span&gt; &lt;span class="nv"&gt;$log&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// ← has to be injected just to add a tag&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$log&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'projectId'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$project&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getId&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="nv"&gt;$log&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="c1"&gt;// ...actual work...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With Span, it's much simpler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;Message&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;Document&lt;/span&gt; &lt;span class="nv"&gt;$project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Span&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'projectId'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$project&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getId&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="nc"&gt;Span&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="c1"&gt;// ...actual work...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why not just make the logger methods static?
&lt;/h2&gt;

&lt;p&gt;Because Appwrite's codebase leverages coroutines (via Swoole) for concurrency between requests, similar to goroutines in Go. A naive static implementation would leak state across concurrent requests. Span solves this by allowing you to choose the storage type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Span&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;setStorage&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;Storage\Coroutine&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Exporters
&lt;/h2&gt;

&lt;p&gt;To combine both logger and console capabilities, Span exposes built-in Exporters, which, as the name suggests, export the logs to not just stdout but any supported adapter. The library currently supports three:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Stdout&lt;/strong&gt;: basic usage. Dumps the output as plain JSON:
&lt;/li&gt;
&lt;/ol&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;"action"&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.deletes"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"span.trace_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7a3f9c2b4e1d8f06"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"span.duration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1.92&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"project.id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"67f3a9"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"project.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;"projects"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"project.region"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fra"&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;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pretty&lt;/strong&gt;: JSON dumps are very useful in production where you have OpenTelemetry or other monitoring set up, but locally you just want things to be readable:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;worker.deletes · 1.92s · 7a3f9c2b

  project.id      67f3a9
  project.type    projects
  project.region  fra

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

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Sentry&lt;/strong&gt;: since Sentry is primarily an error tracking system, Span also exposes a callable "sampler" that lets you filter which logs get exported to a particular exporter:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Span&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;addExporter&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;Sentry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dsn&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'...'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="c1"&gt;// Sampler: drop noisy expected errors, keep everything else.&lt;/span&gt;
    &lt;span class="n"&gt;sampler&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Span&lt;/span&gt; &lt;span class="nv"&gt;$span&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$span&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getError&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;ExecutorException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$error&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isPublishable&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;h2&gt;
  
  
  Before and after
&lt;/h2&gt;

&lt;p&gt;One massive improvement we saw was with error logs. Before, we had very verbose and noisy errors that were often hard to make sense of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Error] Timestamp: 2026-04-17T10:32:16+00:00
[Error] Type: Utopia\Database\Exception\Timeout
[Error] Message: Query took too long
[Error] File: /usr/src/code/src/Appwrite/Cloud/Platform/Workers/Deletes.php
[Error] Line: 214
Trace: #0 /usr/src/code/app/worker.php(828): ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now:&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;"action"&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.deletes"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"span.trace_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7a3f9c2b4e1d8f06"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"span.duration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;2.14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"project.id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"67f3a9"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error.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;"Utopia&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;Database&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;Exception&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;Timeout"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error.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;"Query took too long"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error.file"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/usr/src/code/src/Appwrite/Cloud/Platform/Workers/Deletes.php"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error.line"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;214&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error.trace"&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;"file"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/usr/src/code/app/worker.php"&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"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;828&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"function"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"action"&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;If you're writing PHP in 2026, give &lt;a href="https://github.com/utopia-php/span" rel="noopener noreferrer"&gt;utopia-php/span&lt;/a&gt; a shot. And a massive shoutout to &lt;a href="https://twitter.com/lukebsilver" rel="noopener noreferrer"&gt;@lukebsilver&lt;/a&gt;, who actually built the library. I just learned from him and wanted to share what I picked up.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>php</category>
      <category>beginners</category>
    </item>
    <item>
      <title>JStack + Appwrite: A Match Made in Heaven for Modern Web Development</title>
      <dc:creator>Chirag Aggarwal</dc:creator>
      <pubDate>Sun, 06 Jul 2025 15:27:22 +0000</pubDate>
      <link>https://dev.to/chiragagg5k/jstack-appwrite-a-match-made-in-heaven-for-modern-web-development-1cln</link>
      <guid>https://dev.to/chiragagg5k/jstack-appwrite-a-match-made-in-heaven-for-modern-web-development-1cln</guid>
      <description>&lt;p&gt;If I had a penny for each time a youtuber has launched his own tech stack, I would have 2 pennies, which isn't much but its weird that it happened twice.&lt;/p&gt;

&lt;p&gt;I am talking about &lt;a href="https://create.t3.gg/" rel="noopener noreferrer"&gt;T3 Stack&lt;/a&gt; launched a while back by everyone's favourite Theo, but recently a new player has entered the market called &lt;a href="https://jstack.app/" rel="noopener noreferrer"&gt;JStack&lt;/a&gt;, by Josh who is the lead Devrel at Upstash. To be fair, its not even that recent, but as always &lt;del&gt;I am late to the party&lt;/del&gt; I try to give a framework time to mature and gather feedback from community before giving it a shot.&lt;/p&gt;

&lt;p&gt;So, did I prefer JStack over T3 stack? Did it have more compatibility with Appwrite, my favourite backend provider? Can it be hosted on Appwrite Sites? Let's find out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting Started
&lt;/h2&gt;

&lt;p&gt;Let's start with initialising the project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bunx create-jstack-app@latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Options selected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;┌   jStack CLI 
│
◇  What will your project be called?
│  testing-jstack-appwrite
│
◇  Which database ORM would you like to use?
│  None
│
◇  Should we run &lt;span class="s1"&gt;'bun install'&lt;/span&gt; &lt;span class="k"&gt;for &lt;/span&gt;you?
│  Yes

Using: bun

✔ testing-jstack-appwrite scaffolded successfully!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let's just quickly run a dev server to see what we get 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="nb"&gt;cd cd &lt;/span&gt;testing-jstack-appwrite
bun dev
&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.amazonaws.com%2Fuploads%2Farticles%2Fz9c9q9ae61yzugugap9b.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%2Fz9c9q9ae61yzugugap9b.png" alt="JStack landing page" width="800" height="544"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Initialising Project
&lt;/h2&gt;

&lt;p&gt;On skipping the ORM option, the stack still sets up a &lt;code&gt;/src/server&lt;/code&gt; folder with an example posts router. But it only mocks the DB using an array which does not persist:&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;// Mocked DB&lt;/span&gt;
&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Post&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
  &lt;span class="nx"&gt;name&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Post&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="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="mi"&gt;1&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;Hello World&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;We skipped the ORM option because Appwrite provides built-in schema management through its SDK, eliminating the need for a separate ORM layer. To get started, let's head over to &lt;a href="https://cloud.appwrite.io/" rel="noopener noreferrer"&gt;https://cloud.appwrite.io&lt;/a&gt; to set up our project. &lt;/p&gt;

&lt;p&gt;If you are using Appwrite for the first time, I highly recommend you check out our &lt;a href="https://appwrite.io/docs/quick-starts/web" rel="noopener noreferrer"&gt;Start with Web docs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Here is a quick setup guide:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create a new project -&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%2Fz6w0nn7x3pt9x8tott78.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%2Fz6w0nn7x3pt9x8tott78.png" alt="New project model (Appwrite)" width="800" height="522"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Add a new web platform and select Next.js -&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%2Fw2cvx6dkh8zdrntpy4js.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%2Fw2cvx6dkh8zdrntpy4js.png" alt="New platform screen (Appwrite)" width="800" height="333"&gt;&lt;/a&gt;    &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Go to project overview and grab your project's ID and region-specific endpoint:&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%2Fjom1n3wjbri8qpko7z7a.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%2Fjom1n3wjbri8qpko7z7a.png" alt="Project ID and endpoint (Appwrite)" width="798" height="126"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create a new &lt;code&gt;.env&lt;/code&gt; file in your project directory and paste in those values:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NEXT_PUBLIC_APPWRITE_PROJECT_ID=686a271700323696d223
NEXT_PUBLIC_APPWRITE_ENDPOINT=https://fra.cloud.appwrite.io/v1
NEXT_PUBLIC_APP_DOMAIN=localhost # we will change it later
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Now, let's initialise the Appwrite SDK in the project using:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;bun&lt;/span&gt; &lt;span class="nx"&gt;add&lt;/span&gt; &lt;span class="nx"&gt;appwrite&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This covers up on how to initialize Appwrite in normal Next.js project. Now we need to configure our Appwrite databases according to the project. For this demo, let's create a &lt;code&gt;Posts&lt;/code&gt; collection to shift the mock database JStack uses as an example to Appwrite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defining Schema
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Go to the Appwrite Console &amp;gt; Databases &amp;gt; Create Database. We will call it &lt;code&gt;main&lt;/code&gt; , and keep it' ID as main as well:&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%2Frsmpij8n1yqok80pfm15.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%2Frsmpij8n1yqok80pfm15.png" alt="Create database (Appwrite)" width="800" height="800"&gt;&lt;/a&gt;    &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Similarly, create a collection &lt;code&gt;posts&lt;/code&gt; and keep its ID as &lt;code&gt;posts&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Each document in Appwrite already has an unique ID attached to it, so the only attribute we need is &lt;code&gt;name&lt;/code&gt; for now:&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%2Fcaheyx67pondngmv139t.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%2Fcaheyx67pondngmv139t.png" alt="Create string attribute (Appwrite)" width="800" height="858"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lastly, you will need to define who can access your collection. To learn more about it, check out the docs for &lt;a href="https://appwrite.io/docs/advanced/platform/permissions" rel="noopener noreferrer"&gt;Appwrite Permissions&lt;/a&gt;. For now, we will set it to any:&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%2F2m4jl1939fxqa93olacw.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%2F2m4jl1939fxqa93olacw.png" alt="Create permissions" width="799" height="191"&gt;&lt;/a&gt; &lt;/p&gt;

&lt;h2&gt;
  
  
  Syncing Types
&lt;/h2&gt;

&lt;p&gt;That's it, we are all set with configuration on the Appwrite Console. Now let's set it up in our project. I will also take the help of the Appwrite CLI to help me set up things faster. You can learn more about how to install it by following the installation docs.&lt;/p&gt;

&lt;p&gt;Once done, run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;appwrite init project

? How would you like to start? Link directory to an existing project
? Choose your organization 67610b8ee51f147ca943
? Choose your Appwrite project. &lt;span class="o"&gt;[&lt;/span&gt;object Object]
✓ Success: Project successfully linked. Details are now stored &lt;span class="k"&gt;in &lt;/span&gt;appwrite.json file.
Would you like to pull all resources from project you just linked? Yes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once done, let's utilize the last Types Generation feature to sync our defined types:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;appwrite types src/types       

ℹ Info: Detected language: ts
ℹ Info: Directory: src/types does not exist, creating...
ℹ Info: Found 1 collections: posts
ℹ Info: Found 1 attributes across all collections
ℹ Info: Added types to src/types/appwrite.d.ts
✓ Success: Generated types &lt;span class="k"&gt;for &lt;/span&gt;all the listed collections
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result will look something like 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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Models&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="s1"&gt;appwrite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * This file is auto-generated by the Appwrite CLI. 
 * You can regenerate it by running `appwrite types -l ts src/types`.
 */&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Document&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;string&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;Obviously pretty small for now, but a really helpful feature once the project expands and more collections are defined.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuring Code
&lt;/h2&gt;

&lt;p&gt;Final steps are to connect the Appwrite backend with our tech stack. For that let's create a simple &lt;code&gt;appwrite.ts&lt;/code&gt; file in &lt;code&gt;src/lib&lt;/code&gt; folder:&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="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;Databases&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;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;appwrite&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="nf"&gt;setEndpoint&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;NEXT_PUBLIC_APPWRITE_ENDPOINT&lt;/span&gt;&lt;span class="o"&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;setProject&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;NEXT_PUBLIC_APPWRITE_PROJECT_ID&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;databases&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;Databases&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&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;databases&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, we can modify the original &lt;code&gt;post-router.ts&lt;/code&gt; file to use the defined database:&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="k"&gt;import&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="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod&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;j&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;publicProcedure&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;../jstack&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;databases&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;@/lib/appwrite&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;type&lt;/span&gt; &lt;span class="nx"&gt;Posts&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;@/types/appwrite&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;ID&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;appwrite&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;DATABASE_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;main&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;POSTS_COLLECTION_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;postRouter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;router&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;recent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;publicProcedure&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&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;c&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;posts&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;databases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;listDocuments&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Posts&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;DATABASE_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;POSTS_COLLECTION_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;return&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;superjson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at&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="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;

  &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;publicProcedure&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;input&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;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mutation&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;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&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;post&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;databases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;createDocument&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Posts&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;DATABASE_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;POSTS_COLLECTION_ID&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="nf"&gt;unique&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="nx"&gt;input&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="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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;superjson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&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;And done! Now your JStack application is using Appwrite as its backend provider.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Start the dev server if not already by running:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;bun&lt;/span&gt; &lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="nx"&gt;dev&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Your application should have started on: &lt;a href="http://localhost:3000/" rel="noopener noreferrer"&gt;http://localhost:3000/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Create a new post.&lt;/li&gt;
&lt;li&gt;You should be able to see it in recent posts:&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%2Foy9htyvp77qqct8grhzf.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%2Foy9htyvp77qqct8grhzf.png" alt="Recent posts image" width="800" height="464"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Also, the data should be visible in your Appwrite Console:&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%2Foce30jvc16lb80e7v9qu.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%2Foce30jvc16lb80e7v9qu.png" alt="Appwrite console showing data" width="800" height="153"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploying your application
&lt;/h2&gt;

&lt;p&gt;Until recently, to deploy your Next.js application, you had pretty low choices. But no more &lt;a href="https://appwrite.io/products/sites" rel="noopener noreferrer"&gt;Appwrite Sites&lt;/a&gt;. Now, you can have your backend and frontend hosted on Appwrite.&lt;/p&gt;

&lt;p&gt;Check out these docs on how to get started with Sites - &lt;a href="https://appwrite.io/docs/advanced/self-hosting/sites" rel="noopener noreferrer"&gt;https://appwrite.io/docs/advanced/self-hosting/sites&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let's deploy your application using Sites:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Go to Appwrite Console &amp;gt; Sites &amp;gt; Create Site.&lt;/li&gt;
&lt;li&gt;You can upload a tar file directly, or connect to a GitHub repository (I prefer the GitHub option for automatic deployments).&lt;/li&gt;
&lt;li&gt;Select the repository:&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%2Fe6ww9m9mb2z7w8hc5ggk.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%2Fe6ww9m9mb2z7w8hc5ggk.png" alt="Selecting repository screen" width="800" height="353"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You can keep all the settings as the default ones, just make sure to upload the environment variables we defined earlier:&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%2Fwsthkangby5vgqfnntio.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%2Fwsthkangby5vgqfnntio.png" alt="Add environment variables screen" width="800" height="213"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Depending on the domain you are assigned / plan to use, you also need to update the &lt;code&gt;APP_DOMAIN&lt;/code&gt; variable we defined earlier. For me I will keep it:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_APP_DOMAIN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nx"&gt;jstack&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;appwrite&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;template&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;appwrite&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;network&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Click on deploy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And done! Your application should be live 🎉&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%2Fmfxrz2dfa7nhwu26n93o.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%2Fmfxrz2dfa7nhwu26n93o.png" alt="Deployment successful screenshot" width="800" height="458"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can checkout the demo application here - &lt;a href="https://jstack-appwrite-template.appwrite.network/" rel="noopener noreferrer"&gt;https://jstack-appwrite-template.appwrite.network/&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;Let's start by answering the question the blog began with: Do I prefer JStack over T3 Stack? I'm sorry, Theo, but I do.&lt;/p&gt;

&lt;p&gt;JStack solves the fundamental problems I have always had with Next.js:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Using &lt;a href="https://hono.dev/" rel="noopener noreferrer"&gt;Hono&lt;/a&gt; instead of Next.js's inbuilt convention for defining API routes.&lt;/li&gt;
&lt;li&gt;Using &lt;a href="https://tanstack.com/" rel="noopener noreferrer"&gt;TanStack&lt;/a&gt; query out of the box. Trust me, you should start now if you are not using it.&lt;/li&gt;
&lt;li&gt;Type safe and uses &lt;a href="https://zod.dev/" rel="noopener noreferrer"&gt;Zod&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The most noticeable difference between T3 Stack and JStack is how light they are, mainly due to the inclusion of the tRPC protocol in T3 Stack. Although JStack also uses tRPC, it seems like a much simpler implementation of it. Most projects do not require the complexity tRPC adds, and in my opinion it makes the code 10x more complicated to maintain.&lt;/p&gt;

&lt;p&gt;So, give JStack a shot if you are starting with a new project (or want to spend a weekend migrating your existing stack to it). Huge shoutout to &lt;a href="https://www.youtube.com/@joshtriedcoding" rel="noopener noreferrer"&gt;Josh&lt;/a&gt; for creating this wonderful stack. And lastly, give Appwrite a chance to be your next all in one cloud platform, both for your backend and frontend needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources:
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;JStack - &lt;a href="https://jstack.app/" rel="noopener noreferrer"&gt;https://jstack.app/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Appwrite Docs - &lt;a href="https://appwrite.io/docs" rel="noopener noreferrer"&gt;https://appwrite.io/docs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Github repository - &lt;a href="https://github.com/ChiragAgg5k/jstack-appwrite-template" rel="noopener noreferrer"&gt;https://github.com/ChiragAgg5k/jstack-appwrite-template&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>javascript</category>
      <category>node</category>
    </item>
  </channel>
</rss>
