<?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: Ricardo Morato Mateos</title>
    <description>The latest articles on DEV Community by Ricardo Morato Mateos (@0311b).</description>
    <link>https://dev.to/0311b</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%2F4167297%2Ff844c745-b305-4340-bca9-98b0d752a648.png</url>
      <title>DEV Community: Ricardo Morato Mateos</title>
      <link>https://dev.to/0311b</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/0311b"/>
    <language>en</language>
    <item>
      <title>I built a VS Code extension to work with several agents</title>
      <dc:creator>Ricardo Morato Mateos</dc:creator>
      <pubDate>Tue, 20 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/0311b/i-built-a-vs-code-extension-to-work-with-several-agents-14k1</link>
      <guid>https://dev.to/0311b/i-built-a-vs-code-extension-to-work-with-several-agents-14k1</guid>
      <description>&lt;p&gt;Agents write the code, but I have to see it.&lt;/p&gt;

&lt;p&gt;I need to read it, navigate it, jump to a definition, search the project and, when needed, debug it with breakpoints. An IDE already does that, and I didn't want to give it up to have agents in parallel. This post is about why I ended up building my own tool, and how I work with it.&lt;/p&gt;

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

&lt;p&gt;I tried several tools for running agents in parallel and none fully convinced me. This is what happened to me, on my machine and with my way of working:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Superset: on my machine it ate the memory.&lt;/li&gt;
&lt;li&gt;Warp: a very good terminal, but for reading and moving around the code and the documents it fell short for me.&lt;/li&gt;
&lt;li&gt;In general, most of them pulled me out of the editor. They give you a window with chats or terminals, and the code becomes secondary.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They are good tools. They just didn't fit the way I work: I always ended up going back to VS Code to read what the agents had written.&lt;/p&gt;

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

&lt;p&gt;To stay in VS Code, with my navigation, my extensions, my debugger and my search, and add on top what is missing to work with several agents at once.&lt;/p&gt;

&lt;p&gt;From the other tools, having several agents in parallel, each in its own worktree, and knowing at a glance which one needs me. From VS Code, everything else.&lt;/p&gt;

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

&lt;p&gt;Stack is a VS Code extension, open source under the MIT license. Each task has its own &lt;a href="https://git-scm.com/docs/git-worktree" rel="noopener noreferrer"&gt;git worktree&lt;/a&gt;, which is a working copy of the repository with its own branch, and inside it its real agent: &lt;a href="https://code.claude.com/docs/en/overview" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt;, &lt;a href="https://github.com/openai/codex" rel="noopener noreferrer"&gt;Codex CLI&lt;/a&gt; or &lt;a href="https://opencode.ai" rel="noopener noreferrer"&gt;opencode&lt;/a&gt;. The agents run in &lt;a href="https://github.com/tmux/tmux" rel="noopener noreferrer"&gt;tmux&lt;/a&gt;, so they keep working even if I reload the VS Code window.&lt;/p&gt;

&lt;p&gt;A sidebar tells me, for each task, who is working, who needs me and what is ready for review. When an agent asks me something, its question is the line of its row. And a shortcut, Cmd+Alt+J, takes me to the task that has been waiting for me the longest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why inside VS Code
&lt;/h2&gt;

&lt;p&gt;There are five reasons, and all five are things the editor already does.&lt;/p&gt;

&lt;p&gt;I see and navigate the code for real. Each worktree opens as a folder in the editor. Search and Quick Open are limited to the worktree I'm in, so the work of five agents doesn't get mixed in the results.&lt;/p&gt;

&lt;p&gt;I debug for real. Each worktree has its own debug port and its own &lt;code&gt;launch.json&lt;/code&gt;, so I can stop at a breakpoint in one task while another keeps running. It comes set up for Node and can be changed to any debugger VS Code supports. I use it less and less, but that doesn't mean it should be left aside.&lt;/p&gt;

&lt;p&gt;I review without leaving. There is a diff panel with per-line comments, sent straight to the agent, without going through a PR.&lt;/p&gt;

&lt;p&gt;I run each task's environment. Each worktree can have its own docker compose, with generated ports that don't collide, and a setup script (dependencies, the &lt;code&gt;.env&lt;/code&gt;) that runs before the agent starts.&lt;/p&gt;

&lt;p&gt;I use the real CLIs. It's my &lt;code&gt;claude&lt;/code&gt; or my &lt;code&gt;codex&lt;/code&gt;, with my configuration and my subscription. It's not a reimplementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  A normal day
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;I write the task in New task. Stack creates the branch and the worktree, prepares the environment and launches the agent.&lt;/li&gt;
&lt;li&gt;While they work, I read and navigate the code in the editor. When one asks me something, I see it in its row.&lt;/li&gt;
&lt;li&gt;I review the diff in the editor, comment and send it back to the agent.&lt;/li&gt;
&lt;li&gt;If something doesn't add up, I debug it right there.&lt;/li&gt;
&lt;li&gt;The latest thing, which I'm just starting to use: Stack includes an &lt;a href="https://modelcontextprotocol.io/docs/getting-started/intro" rel="noopener noreferrer"&gt;MCP&lt;/a&gt; server and I use the Claude Code panel as a manager. I type &lt;code&gt;/stack-task&lt;/code&gt; with a ticket, it reads the ticket with the Jira MCP and sets up the task.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What you should know
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;It works on macOS and Linux. It needs tmux. On Windows, only with WSL. Native notifications are macOS only.&lt;/li&gt;
&lt;li&gt;It's not on the VS Code Marketplace yet: you install it from the &lt;code&gt;.vsix&lt;/code&gt; file. It's version 0.1.0.&lt;/li&gt;
&lt;li&gt;To show live state, Stack registers a hook in each agent. In Claude Code it writes it to your &lt;code&gt;~/.claude/settings.json&lt;/code&gt;, and the first time it changes a configuration file it keeps the original as a copy with the &lt;code&gt;.stack-backup&lt;/code&gt; extension.&lt;/li&gt;
&lt;li&gt;With Codex there is a catch. Its hooks are experimental and disabled by default, and you have to enable them in its configuration. Until then, its sessions don't change state in the sidebar. Codex also asks you to trust the hooks before running them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How I improved it
&lt;/h2&gt;

&lt;p&gt;I've been improving it with agents in parallel: some reviewed the code by area, others fixed the bugs, each in its own worktree, and then came the redesign. Today it has 2,102 tests, with 100% coverage of the code it measures. The React views, which are the interface, are left out of that measurement.&lt;/p&gt;

&lt;p&gt;Agents write the code, but I'm the one who has to understand it. That's why I stay in the editor.&lt;/p&gt;

&lt;p&gt;The code is on &lt;a href="https://github.com/ricardomoratomateos/stack" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, under the MIT license.&lt;/p&gt;

</description>
      <category>vscode</category>
      <category>ai</category>
      <category>opensource</category>
      <category>productivity</category>
    </item>
    <item>
      <title>A base agent with LangGraph: tools, memory, RAG and examples</title>
      <dc:creator>Ricardo Morato Mateos</dc:creator>
      <pubDate>Sun, 18 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/0311b/a-base-agent-with-langgraph-tools-memory-rag-and-examples-47jg</link>
      <guid>https://dev.to/0311b/a-base-agent-with-langgraph-tools-memory-rag-and-examples-47jg</guid>
      <description>&lt;p&gt;When I start a new agent I always start from the same base. I use it every day in macro, my app for logging what I eat: I tell it what I had, or send it a photo of the plate, and it logs it with its calories and macros. I also ask it how today is going, or correct something I logged wrong.&lt;/p&gt;

&lt;p&gt;It is JavaScript with &lt;a href="https://docs.langchain.com/oss/javascript/langgraph/graph-api" rel="noopener noreferrer"&gt;LangGraph.js&lt;/a&gt;, Fastify and MongoDB. The code in this post is simplified so the idea shows, but the decisions are the ones macro has.&lt;/p&gt;

&lt;p&gt;macro does not need RAG: everything it knows is the user's own data, and it reads it with tools. When an agent does need knowledge that is not in your database, like documentation or policies, I add RAG on top of the same base. I tell that part with what I did in &lt;a href="https://aside.pro" rel="noopener noreferrer"&gt;aside&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I mean by agent
&lt;/h2&gt;

&lt;p&gt;A model that decides which tool to call, looks at the result and decides again, in a loop, until it has an answer. Anthropic describes it this way in &lt;a href="https://www.anthropic.com/engineering/building-effective-agents" rel="noopener noreferrer"&gt;Building effective agents&lt;/a&gt;: the basic building block is "an LLM enhanced with augmentations such as retrieval, tools, and memory", and the agent is that LLM using tools in a loop based on what the environment returns.&lt;/p&gt;

&lt;p&gt;The same article recommends finding the simplest solution possible and only adding complexity when needed. That is why my base has two nodes and nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The graph: two nodes and a loop
&lt;/h2&gt;

&lt;p&gt;One node calls the model. The other runs the tools the model asked for. If the model asks for a tool, it goes to the tools node and back to the model. If it asks for none, it ends.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;StateGraph&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;START&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MessagesAnnotation&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;@langchain/langgraph&lt;/span&gt;&lt;span class="dl"&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;ToolNode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;toolsCondition&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;@langchain/langgraph/prebuilt&lt;/span&gt;&lt;span class="dl"&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;SystemMessage&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;@langchain/core/messages&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildGraph&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;systemPrompt&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getLLM&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;bindTools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;callModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&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;SystemMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;systemPrompt&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;windowForModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;graph&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;StateGraph&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MessagesAnnotation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;callModel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tools&lt;/span&gt;&lt;span class="dl"&gt;'&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;ToolNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEdge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;START&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addConditionalEdges&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;toolsCondition&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEdge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tools&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;checkpointer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;getCheckpointer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;recursionLimit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;configurable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;thread_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;config&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;toolsCondition&lt;/code&gt; checks whether the model's last message has tool calls and picks the next node. &lt;code&gt;recursionLimit&lt;/code&gt; is the brake: the maximum number of steps the graph can take in one run. &lt;a href="https://docs.langchain.com/oss/javascript/langgraph/graph-api" rel="noopener noreferrer"&gt;LangGraph sets it to 25 by default&lt;/a&gt;, and I make it explicit so it is visible. If the model gets stuck in a tool loop, the run fails instead of burning tokens forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tools: what the agent can do
&lt;/h2&gt;

&lt;p&gt;macro has about twenty tools: log a meal, list, search, edit, delete, read the daily summary, set goals, log weight and water, look up a barcode or scan a photo.&lt;/p&gt;

&lt;p&gt;Three decisions I repeat in any agent:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One source of tools.&lt;/strong&gt; The same tools feed the chat and an &lt;a href="https://modelcontextprotocol.io/specification/2025-06-18" rel="noopener noreferrer"&gt;MCP&lt;/a&gt; server, so other AI clients can use my data. Each tool calls an application use case, the same one the REST API uses. The chat only chooses which ones to expose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The code sets the user, never the model.&lt;/strong&gt; The &lt;code&gt;userId&lt;/code&gt; is not a tool argument. It is injected from the session when the tool is created, so the model cannot ask for another user's data even if someone tries to slip that into a message.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Errors come back as data.&lt;/strong&gt; If a tool fails, I return &lt;code&gt;{ error }&lt;/code&gt; instead of throwing. The model reads it and can correct itself: ask for the missing piece or try another id.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;tool&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;@langchain/core/tools&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createChatTools&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;userId&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;registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listTools&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;CHAT_TOOL_NAMES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;tool&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;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;callTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&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="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&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;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tool failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inputSchema&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Each tool's description matters as much as its code. It is all the model knows about it. Anthropic calls it the agent-computer interface, and recommends putting as much care into it as into an interface for people, with usage examples and edge cases. Which brings me to examples.&lt;/p&gt;

&lt;h2&gt;
  
  
  Examples in the prompt (few-shot)
&lt;/h2&gt;

&lt;p&gt;Few-shot means giving the model a few examples of what you want inside the prompt itself, without retraining it. The name comes from &lt;a href="https://arxiv.org/abs/2005.14165" rel="noopener noreferrer"&gt;Language Models are Few-Shot Learners&lt;/a&gt;, the GPT-3 paper. Today it is one of the cheapest tools you have: &lt;a href="https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices" rel="noopener noreferrer"&gt;Claude's prompting guide&lt;/a&gt; says examples are one of the most reliable ways to steer format, tone and structure, and recommends 3 to 5, close to the real use case, varied and kept apart from the instructions.&lt;/p&gt;

&lt;p&gt;I use them in three places.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In each tool's description, with real phrases.&lt;/strong&gt; I do not write "searches the meal history", I write the phrases people actually say. macro's users write in Spanish, so the examples are in Spanish:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;search_meals: search the user's meal history by name and macro/date filters.
Use for "¿qué cenas con más proteína comí esta semana?", "busca tortilla",
"desayunos de más de 400 kcal de los últimos 14 días".
Prefer this over list_meals when the user is looking for specific meals.

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

&lt;/div&gt;



&lt;p&gt;With that, the model does not have to guess when to use &lt;code&gt;search_meals&lt;/code&gt; and when &lt;code&gt;list_meals&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In the rules, as sequences.&lt;/strong&gt; Corrections are what breaks most if you do not spell them out. So the prompt says what to do with "it was yesterday", "make it 600 kcal" or "move it to dinner": first &lt;code&gt;list_meals&lt;/code&gt; for the right day, then &lt;code&gt;update_meal&lt;/code&gt; on the right id. It is an example of what to do, not only of what to say.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For tone, a written conversation.&lt;/strong&gt; In aside I do not describe the tone with adjectives. I describe a support person and write sample sentences of theirs: how they greet, how they ask for a detail, how they say something cannot be done. Every model reads an adjective like "friendly" its own way. A written sentence, it does not.&lt;/p&gt;

&lt;p&gt;If what you need is an exact output format, you can also put the examples as user and assistant message pairs before the conversation. In an agent with tools I prefer to keep them in the system prompt, because they do not mix with the real history.&lt;/p&gt;

&lt;h2&gt;
  
  
  The prompt: scope, rules and the date
&lt;/h2&gt;

&lt;p&gt;macro's system prompt has four parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Scope.&lt;/strong&gt; It only helps with the user's diet. If asked something else, it deflects in one sentence and offers something useful, without explaining why it will not answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The tools and when to use each one&lt;/strong&gt; , with the examples above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rules.&lt;/strong&gt; Read the daily summary before answering how today is going, never save a photo result without confirming, answer in one or two sentences after a tool, never moralize.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The date.&lt;/strong&gt; Computed on every message.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The date looks trivial and it is not. The model does not know what day it is, and "yesterday" or "on Monday" have to be resolved. So the prompt carries today, yesterday and tomorrow already computed, plus the rule to use the current year when the user does not say one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildSystemPrompt&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;today&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SYSTEM_PROMPT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;

Current date: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;today&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (UTC).
- If the user does not mention a date, use &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;today&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.
- "ayer" / "yesterday" is &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;shiftDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;today&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="s2"&gt;.
- A day and month without a year use the current year.
Never invent a past or future year.`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;I also pass a precomputed summary of the day: what was consumed, the target and what is left. With the instruction to use the tools for exact answers and the summary only as backup.&lt;/p&gt;

&lt;p&gt;The last rule in the prompt treats any user message or image as data, never as instructions. A rule in the prompt is not a guarantee, but it helps. The real guarantee lives in the code: the injected &lt;code&gt;userId&lt;/code&gt; and the confirmation before deleting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Memory: the thread in Mongo and a short window
&lt;/h2&gt;

&lt;p&gt;LangGraph keeps the conversation memory with a checkpointer. According to its &lt;a href="https://docs.langchain.com/oss/javascript/langgraph/persistence" rel="noopener noreferrer"&gt;persistence docs&lt;/a&gt;, the checkpointer saves the graph state per thread, and the thread is picked with a &lt;code&gt;thread_id&lt;/code&gt; in the config. I use the &lt;a href="https://github.com/langchain-ai/langgraphjs/tree/main/libs/checkpoint-mongodb" rel="noopener noreferrer"&gt;MongoDB checkpointer&lt;/a&gt; with the app's own connection and the &lt;code&gt;userId&lt;/code&gt; as &lt;code&gt;thread_id&lt;/code&gt;: one conversation per user, which survives reloads and moves across devices for free.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;MongoDBSaver&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;@langchain/langgraph-checkpoint-mongodb&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;checkpointer&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;MongoDBSaver&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;mongoose&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getClient&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;dbName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;mongoose&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;databaseName&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;Saving everything does not mean sending everything to the model. Each message only carries the last 10, so the cost does not grow with the conversation. The full history stays in Mongo and the UI loads it from there.&lt;/p&gt;

&lt;p&gt;There is a catch when cutting. Every tool result has to come after the call that asked for it. &lt;a href="https://developers.openai.com/api/docs/guides/function-calling" rel="noopener noreferrer"&gt;OpenAI's API&lt;/a&gt; requires each result to reference its call by id. If the window starts right on a tool result, its call was left out and the request fails. So the window never starts on one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_CONTEXT_MESSAGES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;windowForModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;MAX_CONTEXT_MESSAGES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;max&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;messages&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;getType&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tool&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&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;
  
  
  RAG: when the agent needs to know things
&lt;/h2&gt;

&lt;p&gt;RAG is retrieving relevant text and giving it to the model so it answers with it. The term comes from &lt;a href="https://arxiv.org/abs/2005.11401" rel="noopener noreferrer"&gt;this 2020 paper&lt;/a&gt;. What matters is where that text comes from.&lt;/p&gt;

&lt;p&gt;In macro, what the agent needs to know is the user's meals. That is structured data, so retrieval is &lt;code&gt;search_meals&lt;/code&gt;, a Mongo query with filters on name, date and macros. You do not need embeddings to find "breakfasts over 400 kcal".&lt;/p&gt;

&lt;p&gt;In aside it was needed: the agent answers with each customer's documentation. I set it up the same way, as one more tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;searchDocs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tool&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;query&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;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;searchKbDocuments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;workspaceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;query&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;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;excerpt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;})),&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;search_kb&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Search the knowledge base for articles relevant to the user's question. &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
      &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Use when the user asks something that might be documented.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;query&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;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Concise search query&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;As a tool, the model decides when to search and with which words, and can search twice if the first try is not enough. I return it trimmed and with its source, so the UI can show the source under the answer.&lt;/p&gt;

&lt;p&gt;My rule of thumb:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Few documents:&lt;/strong&gt; search by terms, which is what I use in aside.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Many documents, or users who ask with different words than the docs use:&lt;/strong&gt; embeddings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And what comes out of the search is external content. &lt;a href="https://genai.owasp.org/llmrisk/llm01-prompt-injection/" rel="noopener noreferrer"&gt;OWASP&lt;/a&gt; calls it indirect injection: instructions that arrive inside a website or a document. It is treated as data, just like the user's message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before deleting, ask
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;delete_meal&lt;/code&gt; does not delete. It returns a pending confirmation with the id, and the UI shows a button. The real delete is done by the REST API when the user presses it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;delete_meal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;pendingConfirmation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;delete&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;The prompt tells the model to call the tool once and say that the user has to confirm with the button. OWASP recommends the same for any risky action: a person approves it. Photos work in a similar way: the agent shows what it detected and saves nothing until the user confirms or corrects it.&lt;/p&gt;

&lt;p&gt;For payments or emails, which cannot be undone, you also need idempotency. I covered it when writing about &lt;a href="https://0311b.com/en/blog/duplicated-webhooks/" rel="noopener noreferrer"&gt;duplicated webhooks&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Streaming
&lt;/h2&gt;

&lt;p&gt;To show the answer while it is written I use &lt;code&gt;streamMode: 'messages'&lt;/code&gt;. The &lt;a href="https://docs.langchain.com/oss/javascript/langgraph/streaming" rel="noopener noreferrer"&gt;streaming docs&lt;/a&gt; explain that each chunk comes with metadata, including the node that produced it. I filter by the &lt;code&gt;agent&lt;/code&gt; node so tool output does not leak into the chat:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&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;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;userMessage&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;streamMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;messages&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;langgraph_node&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extractText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;text&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;When it ends I read the thread state and check which write tools ran in this turn. That way the UI knows whether to refresh the daily counter or show the confirm button, without parsing the model's text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Voice: listening and talking
&lt;/h2&gt;

&lt;p&gt;aside used to have voice. I removed it because it caused problems, it was expensive and it was not the core. But it worked, and adding it on top of this base is easier than it looks, because the agent does not notice: voice comes in as text and goes out as text.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Listening (STT).&lt;/strong&gt; I used ElevenLabs' Scribe v2 Realtime from the browser, with their React hook. The browser asks my server for a token, so the API key never leaves the server. The mic is turned into 16 kHz audio and sent over a WebSocket. Scribe detects when you stop talking (about 0.8 seconds of silence) and returns the whole sentence. That sentence enters the graph as one more user message.&lt;/p&gt;

&lt;p&gt;What fought back the most was echo. The mic stays open while the agent talks, so you can interrupt it, and it hears the agent itself. So while it talks I drop sentences under four words, and also loose noise of one or two words.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Talking (TTS).&lt;/strong&gt; ElevenLabs Flash v2.5, also over a WebSocket. I do not wait for the model to finish: I pass the text to ElevenLabs as it arrives through the stream, and it starts talking with the first sentence. On this base it would look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;speaker&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;tts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startTurn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;audioSink&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;langgraph_node&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extractText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
  &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;speaker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;feedDelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;stripMarkdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;speaker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Three details made it usable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One socket per turn.&lt;/strong&gt; If each sentence goes on its own, each one sounds like it starts from scratch. With one socket per answer, the intonation carries from one sentence to the next.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strip the markdown before speaking.&lt;/strong&gt; Otherwise it reads the asterisks and hashes out loud.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A cache per sentence.&lt;/strong&gt; I store each sentence's audio in Mongo. The ones that repeat, like the welcome line, come from there without paying again.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And if the user talks over it, the audio stops and a new turn starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Switching models without touching the agent
&lt;/h2&gt;

&lt;p&gt;The agent does not know which model it uses. &lt;code&gt;getLLM()&lt;/code&gt; builds the client from a setting stored in the database: OpenAI by default, Gemini, or MiniMax through its Anthropic-compatible API. The setting is cached for five seconds, so I switch provider from the admin panel without deploying.&lt;/p&gt;

&lt;p&gt;That is what lets me try a new model on my real data in a minute. If the new model misuses a tool, I almost always fix it in the tool's description or in an example, not in the graph.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I leave out at the start
&lt;/h2&gt;

&lt;p&gt;No multiple agents, no planners, no long-term memory, no embeddings up front. The two-node loop, well-described tools, examples with real phrases, the thread in Mongo with a short window, and a confirmation before anything that cannot be undone. With that, macro works every day, and I add the rest when a real case asks for it.&lt;/p&gt;

&lt;p&gt;None of this may be the best way. It came out of trial and error, and it is what works for me.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>langchain</category>
      <category>javascript</category>
      <category>rag</category>
    </item>
    <item>
      <title>Planning with AI: make it ask before it proposes</title>
      <dc:creator>Ricardo Morato Mateos</dc:creator>
      <pubDate>Fri, 16 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/0311b/planning-with-ai-make-it-ask-before-it-proposes-4ppd</link>
      <guid>https://dev.to/0311b/planning-with-ai-make-it-ask-before-it-proposes-4ppd</guid>
      <description>&lt;p&gt;I have a skill for planning features with AI: &lt;code&gt;rstack-plan&lt;/code&gt;, one of the skills in &lt;a href="https://github.com/ricardomoratomateos/rstack" rel="noopener noreferrer"&gt;rstack&lt;/a&gt;. I use it when I describe something I want to build, not a bug. For bugs I have &lt;a href="https://0311b.com/en/blog/a-hard-harness-for-fixing-bugs-with-ai/" rel="noopener noreferrer"&gt;another one&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It does one thing: it listens, checks what already exists and negotiates the plan with me, question by question, before touching code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;The skill starts with a sentence: don't act on assumption, propose or listen, never just do.&lt;/p&gt;

&lt;p&gt;It came from something that happened to me. I asked the AI to think through a new skill together and, instead, it handed me a finished diagram, with its own design, without having first confirmed that it had understood what I wanted. The solution wasn't asking it to think harder. It was a structure that makes jumping ahead impossible.&lt;/p&gt;

&lt;p&gt;Its job ends in an approved plan, saved in the repository. It doesn't write implementation code: whatever happens after approval is separate work.&lt;/p&gt;

&lt;h2&gt;
  
  
  And Claude Code's plan mode
&lt;/h2&gt;

&lt;p&gt;Claude Code already has a plan mode: according to &lt;a href="https://code.claude.com/docs/en/common-workflows" rel="noopener noreferrer"&gt;its documentation&lt;/a&gt;, Claude reads files and proposes a plan, but makes no edits until you approve. It works for not touching the disk.&lt;/p&gt;

&lt;p&gt;What I'm after is something else. A plan that arrives whole, all at once, is a decision made alone with an approval request attached. I want the plan to be built with my answers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four phases
&lt;/h2&gt;

&lt;p&gt;Each phase ends with a stop: the skill tells me what it has done and waits for my answer before continuing.&lt;/p&gt;

&lt;p&gt;The first is listening. It restates my request in its own words, without adding scope. It doesn't search the code or touch anything yet. If the request is ambiguous, it says what is ambiguous instead of resolving it by guessing the obvious interpretation. And it waits for explicit confirmation. A "yes" counts. A correction is not a soft adjustment: it goes back to phase 1 and restates from scratch. There is no exception for size: a request that looks simple goes through the same gate.&lt;/p&gt;

&lt;p&gt;The second is looking at what already exists, read-only. The question isn't how something similar is done here, in the abstract. It's whether something that already exists can be used as is, instead of building something new. For each piece it finds, it says whether it can be reused as is, with changes or not at all, and why. The example the skill gives: if I ask for a new kind of plugin, the right output of this phase is "there is already a working OAuth flow for plugin authentication, reuse it", not a generic review of authentication patterns. This is where the first file is written, &lt;code&gt;specs/&amp;lt;feature&amp;gt;/spec.md&lt;/code&gt;, with the what and the why, never the how.&lt;/p&gt;

&lt;p&gt;The third is proposing, by negotiation. It doesn't hand me a complete plan. It asks me questions one at a time about what the previous phase left open, and the plan is built with my answers. Each question has to be answerable in a single message: it doesn't pack three decisions into one because it's faster. The &lt;code&gt;plan.md&lt;/code&gt; file grows with the conversation: after each answer, the relevant section is updated, not at the end. A file that is only written at the end is the chat transcript with extra steps.&lt;/p&gt;

&lt;p&gt;If the feature has a flow or several actors, the skill draws a diagram and keeps updating it as I decide each piece. For that it uses &lt;a href="https://github.com/tt-a1i/archify" rel="noopener noreferrer"&gt;archify&lt;/a&gt; if it's available. If it isn't, it says so and carries on with text only: it doesn't fake a diagram in ASCII.&lt;/p&gt;

&lt;p&gt;The fourth is the approval gate. There are only two valid answers: "this plan is enough", and then &lt;code&gt;spec.md&lt;/code&gt; and &lt;code&gt;plan.md&lt;/code&gt; are the deliverable and nothing gets implemented; or "yes, implement it", and the skill ends, because implementation is separate work. A "sure", a thumbs up or changing the subject is neither, and it asks again. When the gate closes, it gives me the file paths and doesn't commit them to git: that's up to me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The red flags
&lt;/h2&gt;

&lt;p&gt;These are the signs that force a stop:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Writing the whole plan before asking a single question.&lt;/li&gt;
&lt;li&gt;Drawing a diagram "to show progress" before I have decided that piece.&lt;/li&gt;
&lt;li&gt;Treating an "ok", or silence, as approval.&lt;/li&gt;
&lt;li&gt;Looking for existing code after proposing a new design instead of before. If reuse doesn't come first, it never gets considered.&lt;/li&gt;
&lt;li&gt;Skipping a phase because the request "seems simple".&lt;/li&gt;
&lt;li&gt;Keeping the decisions in its head and writing &lt;code&gt;spec.md&lt;/code&gt; and &lt;code&gt;plan.md&lt;/code&gt; all at once at the end.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Which files it writes
&lt;/h2&gt;

&lt;p&gt;Only three kinds: &lt;code&gt;spec.md&lt;/code&gt;, &lt;code&gt;plan.md&lt;/code&gt; and the drafts of their diagrams. It never touches the application's code.&lt;/p&gt;

&lt;p&gt;It also doesn't create by default the rest of the files from the &lt;a href="https://github.com/github/spec-kit" rel="noopener noreferrer"&gt;spec-kit&lt;/a&gt; convention, GitHub's toolkit for spec-driven development: &lt;code&gt;data-model.md&lt;/code&gt;, &lt;code&gt;contracts/&lt;/code&gt;, &lt;code&gt;research.md&lt;/code&gt; or &lt;code&gt;tasks.md&lt;/code&gt;. It adds one only if that feature needs it, and says why.&lt;/p&gt;

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

&lt;p&gt;It asks more questions and is slower than a plan delivered all at once. That's on purpose. And it isn't for everything: it's meant for features that aren't trivial, not for a bug. A configuration value or a single field doesn't get a diagram, although it does go through the first gate.&lt;/p&gt;

&lt;p&gt;For something new, make it ask before it proposes.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>planning</category>
      <category>claudecode</category>
      <category>workflow</category>
    </item>
    <item>
      <title>A hard harness for fixing bugs with AI</title>
      <dc:creator>Ricardo Morato Mateos</dc:creator>
      <pubDate>Fri, 16 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/0311b/a-hard-harness-for-fixing-bugs-with-ai-22p5</link>
      <guid>https://dev.to/0311b/a-hard-harness-for-fixing-bugs-with-ai-22p5</guid>
      <description>&lt;p&gt;I've lived through the progression of AI from the start: copying code challenges into ChatGPT, then Gemini, then Claude, and now everything agentic, with its harnesses and its tools.&lt;/p&gt;

&lt;p&gt;AI is getting more and more independent. For my personal projects it works well. Starting something from scratch or putting out a new website, inside a small codebase without much shared domain, isn't bad. The problem shows up in day-to-day work: the ticket, the task, the fix, the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened to me
&lt;/h2&gt;

&lt;p&gt;Since Opus 5.5 came out, my impression is that it does worse at that work. I'm not saying it as a fact: it's what I've seen, in my workflow.&lt;/p&gt;

&lt;p&gt;It gets too clever when creating tasks and sets aside the harness I had built. Before, with TDD, I would check whether the tests were written and what they implied, and then we would look for the bug. That harness was going quite well for me until recently. In the last ten bugs I've shipped, I've had to guide it step by step myself, because the solutions it gave me weren't correct.&lt;/p&gt;

&lt;p&gt;It's not that I would have done it better or worse. It's that I had the knowledge to know that wasn't what had to be done.&lt;/p&gt;

&lt;p&gt;There's a second thing I don't fully understand: it finds more and more things risky. Querying some logs or running a query against a database that isn't production, something it used to do without any problem, now seems dangerous to it. For now I do it by hand and that's it.&lt;/p&gt;

&lt;p&gt;And a third, deeper one: they sell you that AI can do everything, that it fixes everything and ships everything. In my work, no.&lt;/p&gt;

&lt;h2&gt;
  
  
  The answer: a hard harness
&lt;/h2&gt;

&lt;p&gt;My harness is no longer a soft one, made of suggestions. It's a hard one, where the AI has to follow strict steps and can't step outside them.&lt;/p&gt;

&lt;p&gt;I've published it as &lt;a href="https://github.com/ricardomoratomateos/rstack" rel="noopener noreferrer"&gt;rstack&lt;/a&gt;. They are Claude Code skills: according to &lt;a href="https://code.claude.com/docs/en/skills" rel="noopener noreferrer"&gt;the documentation&lt;/a&gt;, a skill is a &lt;code&gt;SKILL.md&lt;/code&gt; file with instructions that Claude loads when needed, or that you invoke yourself. My skills aren't advice, they are sentences of the kind "don't do X until Y". They were born in the same spirit as &lt;a href="https://github.com/garrytan/gstack" rel="noopener noreferrer"&gt;gstack&lt;/a&gt;, with one design difference: each skill stands on its own, with no shared document that all of them point to.&lt;/p&gt;

&lt;p&gt;The one this post is about is &lt;a href="https://github.com/ricardomoratomateos/rstack/blob/main/rstack-investigate/SKILL.md" rel="noopener noreferrer"&gt;&lt;code&gt;rstack-investigate&lt;/code&gt;&lt;/a&gt;, the one for bugs. The repository also has others for planning features, reviewing PRs and doing QA in the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that isn't negotiable
&lt;/h2&gt;

&lt;p&gt;The skill starts with a rule: no root cause is proposed and no fix is written without having reproduced the bug with real data. Reading the code and reasoning about what should happen is not proof.&lt;/p&gt;

&lt;p&gt;It came from something that happened to me twice in the same session with the AI: it proposed a cause without checking the account's real data, and then it built an entire fix by reading code, which was useless in production because the data it depended on didn't exist. Both times I had to stop it myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The seven phases
&lt;/h2&gt;

&lt;p&gt;The cycle has seven phases, and at the end of each one the skill stops, tells me what it found and asks me before continuing. It doesn't run on its own from start to finish.&lt;/p&gt;

&lt;p&gt;The first is reproduce. It reads the symptom as it arrives, without reinterpreting it, traces the code backwards and looks at the git history in case it's a regression. Then it reproduces the bug with real data, as an automated test inside the repo itself, and watches it fail. It doesn't first write a loose script "to understand": if the project has tests, the reproduction is that test. If a piece of data is missing, it asks for it, it doesn't make it up.&lt;/p&gt;

&lt;p&gt;The second compares with bugs already fixed and rules out the typical patterns: a race condition, a null that propagates, a half-finished state, a failing external integration, configuration that differs between environments, a stale cache. A bug that comes back to the same place is a sign of architecture, not a coincidence.&lt;/p&gt;

&lt;p&gt;The third tests the hypothesis. It adds temporary logs right at the suspected point, runs the same test and reads the output line by line. If three hypotheses fail in a row, it stops and asks me, because it may be an architecture problem and not a wrong hypothesis. Before fixing anything, it removes all the logs.&lt;/p&gt;

&lt;p&gt;The fourth is the fix. The fifth reproduces the original bug again and delivers a report. The sixth checks what looks like the bug, without dismissing it or covering it by ear: if it doesn't apply, it says why.&lt;/p&gt;

&lt;p&gt;The seventh is the audit. A clean subagent, with no context at all, reads all the project's ADRs (&lt;a href="https://adr.github.io/" rel="noopener noreferrer"&gt;architecture decision records&lt;/a&gt;) and all my principles documents, the &lt;a href="https://github.com/ricardomoratomateos/rstack/tree/main/knowledge" rel="noopener noreferrer"&gt;&lt;code&gt;knowledge/&lt;/code&gt;&lt;/a&gt; folder, and checks the solution against each one. Not against the ones that sound relevant: against all of them, with an N-of-N count so it's visible that none was skipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  The red flags
&lt;/h2&gt;

&lt;p&gt;In the third phase there is a list of signs that force a stop:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"A quick fix for now". There is no "for now": you fix it properly or you escalate.&lt;/li&gt;
&lt;li&gt;Proposing a fix before having traced the complete flow of the data. That's guessing.&lt;/li&gt;
&lt;li&gt;Trusting a subagent's reading of the code, or your own, without checking it against the real case. A piece that fits "in theory" can depend on data that doesn't exist in production.&lt;/li&gt;
&lt;li&gt;Doing a find and replace over a shared file to isolate an experiment, without scoping it to your block. After any bulk edit, you look at &lt;code&gt;git diff --stat&lt;/code&gt;: if it touches more lines than expected, you revert it and redo it scoped.&lt;/li&gt;
&lt;li&gt;Wanting to delete or consolidate shared code as a first idea, without having checked who else calls it. It's an assumption dressed up as elegance.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The fix: the least dangerous, not the cleverest
&lt;/h2&gt;

&lt;p&gt;When there is more than one way to fix it, the skill prefers the one that can break the least if I'm wrong. In this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A localized fix, scoped right where the bug lives, with its test. It's the default when I don't know who depends on the shared code next to it.&lt;/li&gt;
&lt;li&gt;A new, small, isolated piece, if the localized fix would force me to dig into logic I don't fully understand. It's more code, but it can't break what I haven't looked at.&lt;/li&gt;
&lt;li&gt;Touching the shared code (consolidating, deleting or adding a guard) only after having checked every place that calls it. It's the strongest fix, because it makes that kind of bug impossible, but if I miss a caller, I break more than I fix.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The regression test is the same one from the first phase, the one that was already failing with real data. You don't write a new one: you apply the fix and check that this one goes green, and then the whole suite. And if the fix touches more than five files, the skill asks me before continuing, because that's too much for a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an integration test
&lt;/h2&gt;

&lt;p&gt;I work with e-commerce flows and, in that kind of system, a bug rarely lives in a single place. It depends on several contexts at once: contacts, taxes, invoicing.&lt;/p&gt;

&lt;p&gt;Each piece on its own is usually fine. Even so the bug exists. That's why an isolated test doesn't work for me: I need an integration test that replicates the whole path. The skill prefers it to a unit test with mocks, because a test that fakes exactly the dependency where the bug lives can make a wrong fix look correct. It's an old debate, classical style versus mockist style, which Fowler sums up in &lt;a href="https://martinfowler.com/articles/mocksArentStubs.html" rel="noopener noreferrer"&gt;Mocks Aren't Stubs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The report
&lt;/h2&gt;

&lt;p&gt;The fifth phase ends with a report shaped like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Symptom: [what the user reported]
Root cause: [what was actually wrong, confirmed with evidence]
Fix: [what changed, with file:line]
Evidence: [test/reproduction output before and after]
Regression test: [file:line]
Status: DONE | DONE_WITH_CONCERNS | BLOCKED

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

&lt;/div&gt;



&lt;p&gt;The skill won't let me say "this should fix it". It proves it with the test and with the original reproduction. And if the test used real production data, it says so, without passing it off as synthetic.&lt;/p&gt;

&lt;h2&gt;
  
  
  When I don't use it
&lt;/h2&gt;

&lt;p&gt;It isn't for a typo or a one-line fix that I've already diagnosed myself. And it's slow by design: seven phases with a question each. That's the price of not letting anything get skipped.&lt;/p&gt;

&lt;p&gt;To start something from scratch, AI on its own is still fine. For a bug, this.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>debugging</category>
      <category>testing</category>
      <category>claudecode</category>
    </item>
    <item>
      <title>Why I rewrote aside</title>
      <dc:creator>Ricardo Morato Mateos</dc:creator>
      <pubDate>Sun, 04 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/0311b/why-i-rewrote-aside-3aj5</link>
      <guid>https://dev.to/0311b/why-i-rewrote-aside-3aj5</guid>
      <description>&lt;p&gt;I've rebuilt &lt;a href="https://aside.pro" rel="noopener noreferrer"&gt;aside&lt;/a&gt; more than once, and the last rewrite is the one that changed the most. Before I say what I changed, the first thing: I tried to sell aside and I couldn't. I'm not saying it with drama, it's what happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tried first
&lt;/h2&gt;

&lt;p&gt;Between March and May I was posting about aside on LinkedIn. I saw no results and I stopped.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it was before
&lt;/h2&gt;

&lt;p&gt;The first version "saw" the screen and decided on the spot where to click or what to highlight. It failed too much. When something fails because of how it's set up, tuning doesn't fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;p&gt;Now it's your app that tells aside what it can do: create an invoice, change a setting, open a screen. aside chooses what to do and with what data, and your own code runs it.&lt;/p&gt;

&lt;p&gt;You tell it "invoice Orbit Labs €2,400 for the month of September" and it leaves the invoice filled in, ready for you to send. What changes is who decides what is possible: your app, not aside guessing.&lt;/p&gt;

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

&lt;p&gt;Before, you could also talk to it by voice. I realised it caused problems, it was very expensive and it wasn't essential. I removed it to focus on making what it does work well every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The price
&lt;/h2&gt;

&lt;p&gt;Before, it started at €249, too much for solo founders and small startups. Now it starts at €49.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before and now
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;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;How it decides what to do&lt;/td&gt;
&lt;td&gt;It saw the screen and decided where to click&lt;/td&gt;
&lt;td&gt;The app tells it what it can do, aside picks and the SaaS's own code runs it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Voice&lt;/td&gt;
&lt;td&gt;You could talk to it&lt;/td&gt;
&lt;td&gt;I removed it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Price&lt;/td&gt;
&lt;td&gt;From €249&lt;/td&gt;
&lt;td&gt;From €49&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What it does today
&lt;/h2&gt;

&lt;p&gt;Two things. The first is onboarding: it takes your users through setup step by step, in a guided and natural way, and fills in each field while they talk. The second is support: it answers with your documentation and, when that isn't enough, it acts.&lt;/p&gt;

&lt;p&gt;aside's own dashboard runs on aside: you tell it in one sentence how you want your agent and it sets it up for you.&lt;/p&gt;

&lt;p&gt;All of this came from talking to customers and seeing where it failed. I don't know if it's the final version, but it is the most polished one.&lt;/p&gt;

&lt;h2&gt;
  
  
  So far, three conversations
&lt;/h2&gt;

&lt;p&gt;I've talked to three leads. That's few, and I'm not going to draw market conclusions from three people. What I'm telling you is what happened.&lt;/p&gt;

&lt;p&gt;One told me the price was expensive. The other two never replied.&lt;/p&gt;

&lt;p&gt;About the one who said it was expensive I know something concrete: for that person, the price is an objection. It's a data point, even if it's only one.&lt;/p&gt;

&lt;p&gt;About the two who disappeared I know nothing. A silence is not a no, and it's not a yes either. It doesn't tell me whether they weren't interested in the product, whether the timing was bad or whether my message didn't manage to say anything to them.&lt;/p&gt;

&lt;p&gt;With three conversations there is no pattern yet.&lt;/p&gt;

</description>
      <category>saas</category>
      <category>buildinpublic</category>
      <category>startup</category>
      <category>ai</category>
    </item>
    <item>
      <title>Why your webhooks arrive duplicated and how to stop them costing an invoice</title>
      <dc:creator>Ricardo Morato Mateos</dc:creator>
      <pubDate>Sun, 27 Sep 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/0311b/why-your-webhooks-arrive-duplicated-and-how-to-stop-them-costing-an-invoice-24gp</link>
      <guid>https://dev.to/0311b/why-your-webhooks-arrive-duplicated-and-how-to-stop-them-costing-an-invoice-24gp</guid>
      <description>&lt;p&gt;In an ERP, a duplicated webhook is not a technical bug. It's a wrongly issued invoice.&lt;/p&gt;

&lt;p&gt;I work on the ecommerce integrations of a SaaS ERP: Shopify, WooCommerce, PrestaShop. Each store sends us events about its orders, and the expensive mistake is always the same: the system does the same thing twice, or does today what was already corrected yesterday. In this post I explain the layers I use to avoid it and why each one exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can't assume about a webhook
&lt;/h2&gt;

&lt;p&gt;A webhook does not guarantee that an event arrives only once or in order. Shopify, for example, &lt;a href="https://shopify.dev/docs/apps/build/webhooks/best-practices" rel="noopener noreferrer"&gt;does not guarantee ordering within a topic or across topics for the same resource&lt;/a&gt; and &lt;a href="https://shopify.dev/docs/apps/build/webhooks/ignore-duplicates" rel="noopener noreferrer"&gt;recommends ignoring duplicates&lt;/a&gt; with the &lt;code&gt;X-Shopify-Webhook-Id&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;With one store it's rare. With thousands of integrations, rare stops being rare: any combination of duplicate, concurrency and disorder happens every day. That's why there is no single solution. There are layers, and each one covers a gap the others don't see.&lt;/p&gt;

&lt;p&gt;Before the layers there is a base rule: the endpoint only verifies the signature, stores the event exactly as it arrives and responds 200. Everything else happens in a worker, so the response does not depend on how long processing takes. Storing the raw event also lets you reprocess and audit it when there is a bug.&lt;/p&gt;

&lt;p&gt;And a webhook is a notification, not the truth. For a critical decision, like issuing an invoice, it's better to request the order again from the store's API instead of trusting the payload you received.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: discard the identical before processing it
&lt;/h2&gt;

&lt;p&gt;The cheapest one. You compute a hash of the payload and store it in Redis with an expiry. If it already exists, the event is a repeat and you discard it without touching the database.&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="c1"&gt;// Illustrative example, not the real code&lt;/span&gt;
&lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"webhook:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$shopDomain&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:"&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&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="c1"&gt;// SET NX EX: check and mark in a single atomic operation&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$redis&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'NX'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'EX'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// already received, discard&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;try&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;inbox&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$rawEvent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// durable, unique per event id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;\Throwable&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$redis&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;del&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// if it wasn't stored, the provider's retry must be able to get through&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nv"&gt;$e&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;What matters is that checking and marking is a single operation, which is exactly what &lt;a href="https://redis.io/docs/latest/commands/set/" rel="noopener noreferrer"&gt;&lt;code&gt;SET&lt;/code&gt; with &lt;code&gt;NX&lt;/code&gt; and &lt;code&gt;EX&lt;/code&gt;&lt;/a&gt; does. If you do a &lt;code&gt;GET&lt;/code&gt; and then a &lt;code&gt;SET&lt;/code&gt;, two copies of the same event that arrive at the same time both get through. The key includes the store because the same id can repeat across different stores.&lt;/p&gt;

&lt;p&gt;Even so, this layer is an optimisation: Redis can lose or expire the key, so the deduplication that guarantees anything is the durable one, with a unique constraint on the event id. What Redis does do is take work off your plate: in my case it discards about a quarter of the events before they use up resources.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: a lock per order
&lt;/h2&gt;

&lt;p&gt;The hash doesn't help against two different events for the same order that arrive almost at once. If two workers process them in parallel, each one reads the order's state, modifies it and writes it, and the last one to write wins.&lt;/p&gt;

&lt;p&gt;The solution is to serialise the work per order with a distributed lock: before processing, the worker takes that order's lock and, if another worker holds it, it retries a bounded number of times, with some randomness in the wait, or puts the event back on the queue. But the lock coordinates, it doesn't prove correctness. What really prevents losing a write is saving with a conditional write by version, which fails if someone got there first.&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="c1"&gt;// Illustrative example, not the real code&lt;/span&gt;
&lt;span class="nv"&gt;$lockKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"lock:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$tenantId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:order:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$orderId&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;span class="nv"&gt;$token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;bin2hex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;random_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// unique per acquisition&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$redis&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$lockKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'NX'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'PX'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;30000&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RetryLater&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// another worker is on this order&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;try&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="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// the final save is a conditional write by version&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Lua script: if get(key) == token then del(key); never GET and then DEL&lt;/span&gt;
    &lt;span class="nv"&gt;$redis&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;eval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;RELEASE_IF_OWNER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$lockKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$token&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details that matter: the lock expires (it's a lease, with a duration longer than the measured time the work takes), the value is a unique token per acquisition and it's released by comparing that token atomically. Even so, if the worker takes longer than the lease, another one gets in while the first is still writing. That's why the lock doesn't replace the conditional write or business idempotency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: ignore what arrives late
&lt;/h2&gt;

&lt;p&gt;Even if they don't overwrite each other, events can arrive out of order. An ordering guard compares the event's date with the date of the last state you have already applied, discards the one that is older and records that it discarded 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="c1"&gt;// Illustrative example, not the real code&lt;/span&gt;
&lt;span class="c1"&gt;// The watermark lives in the order itself&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;emittedAt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;lastEmittedAt&lt;/span&gt;&lt;span class="p"&gt;())&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;logger&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'webhook.stale_discarded'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'orderId'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$orderId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// older than what has already been applied&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// when saving, the same check goes inside the conditional write&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;You have to decide what you compare. The event's date at the provider, not the time it reached you, because delivery can be delayed. If the provider also gives a version or a sortable id, store it: two updates in the same second tie on date. And the check must travel in the conditional write, not only in a previous &lt;code&gt;if&lt;/code&gt;, because between the &lt;code&gt;if&lt;/code&gt; and the save another worker may have got ahead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 4: what a hash can't see
&lt;/h2&gt;

&lt;p&gt;The same refund can arrive by two different routes, with different payloads. To the hash they are two unrelated events, so both get through.&lt;/p&gt;

&lt;p&gt;Here I don't deduplicate messages, I apply business idempotency: the key comes from the identity of the refund (the store and the refund id), not from the content or the delivery. The question changes from "have I seen this message?" to "have I already applied this refund?". That key has to live in the database with a unique constraint and store the result, so a retry returns the same thing. In Redis, which expires, it's not enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't overwrite what the merchant edited by hand
&lt;/h2&gt;

&lt;p&gt;A merchant can edit a document by hand after we have generated it. If another sync event arrives, you don't want to overwrite that edit.&lt;/p&gt;

&lt;p&gt;For that I compare two hashes: one of the fiscal fields and one of the non-fiscal fields. That way I see what has really changed between what I have and what arrives, and I don't flatten a manual edit. So that the hash doesn't invent differences, you have to normalise the amounts to the same precision and sort the keys before computing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The exchange rate, fixed to the original document
&lt;/h2&gt;

&lt;p&gt;A detail that causes false differences: if an order is in another currency and you recalculate with the exchange rate of each event, the volatility between one event and the next makes the amounts not add up even though nothing has changed.&lt;/p&gt;

&lt;p&gt;I fix the exchange rate of the original document across the whole chain of events, so the amount only changes when the order changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary of the layers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What it prevents&lt;/th&gt;
&lt;th&gt;Where the guarantee is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. Hash in Redis&lt;/td&gt;
&lt;td&gt;Processing an identical event twice&lt;/td&gt;
&lt;td&gt;In durable deduplication by event id: Redis only saves work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. Lock per order&lt;/td&gt;
&lt;td&gt;Two workers processing the same order at once&lt;/td&gt;
&lt;td&gt;In the conditional write by version&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. Ordering guard&lt;/td&gt;
&lt;td&gt;An old event overwriting a new one&lt;/td&gt;
&lt;td&gt;In the check inside the conditional write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4. Business idempotency&lt;/td&gt;
&lt;td&gt;Applying the same refund twice&lt;/td&gt;
&lt;td&gt;In a durable key with a unique constraint&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;p&gt;None of these pieces is sophisticated on its own. What's hard is having all of them, and having every new incident come in with a test that stops it from happening again; how I automate that cycle is in &lt;a href="https://dev.to/0311b/from-a-slack-thread-to-a-pull-request-an-agent-that-reproduces-the-incident-1ah7-temp-slug-9600754"&gt;From a Slack thread to a pull request&lt;/a&gt;. With the four layers and those two precautions, a repeated or out-of-order event should not change the order twice.&lt;/p&gt;

&lt;p&gt;And since webhooks also get lost, the system has to converge even if none arrives: that takes a periodic reconciliation against the store, which &lt;a href="https://shopify.dev/docs/apps/build/webhooks/best-practices" rel="noopener noreferrer"&gt;Shopify also recommends&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webhooks</category>
      <category>architecture</category>
      <category>backend</category>
      <category>redis</category>
    </item>
    <item>
      <title>From timeout to 10 seconds: finding the real bottleneck</title>
      <dc:creator>Ricardo Morato Mateos</dc:creator>
      <pubDate>Sun, 20 Sep 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/0311b/from-timeout-to-10-seconds-finding-the-real-bottleneck-3oh1</link>
      <guid>https://dev.to/0311b/from-timeout-to-10-seconds-finding-the-real-bottleneck-3oh1</guid>
      <description>&lt;p&gt;There was a screen that, on large accounts, would not open.&lt;/p&gt;

&lt;p&gt;It was the listing of document items. On accounts with a lot of volume it hit the nginx timeout, 60 seconds, and for the customer it was simply unreachable. That module was already built. I rebuilt it on my own, frontend and backend.&lt;/p&gt;

&lt;p&gt;The backend is PHP with Symfony and CQRS, and the frontend is React. I simplify the code in this post so the idea is clear.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure before touching
&lt;/h2&gt;

&lt;p&gt;The temptation is to optimise the first thing that smells bad. Before that, I looked at where the time and the memory were going, and not only the total time: a high total time doesn't tell you whether the problem is the database, the ORM or the frontend. The bottleneck was the ORM's memory consumption.&lt;/p&gt;

&lt;p&gt;When an ORM loads many records, it turns each one into objects, and those objects take up memory for as long as they stay alive. With tens of thousands of products, that memory shoots up and the listing never finishes in time.&lt;/p&gt;

&lt;h2&gt;
  
  
  First change: process in batches
&lt;/h2&gt;

&lt;p&gt;Instead of loading all the items at once, I process them in batches and free memory between one and the next. This is the pattern in the &lt;a href="https://www.doctrine-project.org/projects/doctrine-orm/en/current/reference/batch-processing.html" rel="noopener noreferrer"&gt;Doctrine batch processing documentation&lt;/a&gt;, which calls &lt;code&gt;clear()&lt;/code&gt; between batches to free memory.&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;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;array_chunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$batchSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&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="nv"&gt;$items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$repository&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;findByIds&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="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;from&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="nf"&gt;present&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$items&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;clear&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// releases the objects from the previous batch&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;This step still hydrates full entities, but memory stops growing with the size of the account and depends mostly on the size of the batch. That size is a trade-off: bigger performs better, smaller uses less memory, and you choose it by measuring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Second change: a view instead of the aggregate
&lt;/h2&gt;

&lt;p&gt;Even when processing in batches, loading the full aggregate of each item is expensive. The aggregate exists to protect invariants when writing, and to render a list you don't need it. So I applied a view: the listing no longer goes through the aggregate and reads a read projection, a flat object with only the fields the screen renders.&lt;/p&gt;

&lt;p&gt;In CQRS that is a query with its handler, and the handler loads nothing: it asks a read gateway for the data.&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="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ListDocumentItemsQuery&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;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$documentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ListDocumentItemsQueryHandler&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;__construct&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;DocumentItemsReadGateway&lt;/span&gt; &lt;span class="nv"&gt;$items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="cd"&gt;/** @return iterable&amp;lt;DocumentItemView&amp;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;__invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ListDocumentItemsQuery&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;iterable&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;from&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;items&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;documentId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;The handler returns an iterable, not an array. That is the key to everything that comes next: with a generator, only one row is in memory at a time, the one being processed.&lt;/p&gt;

&lt;p&gt;The read gateway is the one that talks to the database. It projects only the fields that are needed, sorts in a stable way and hands out rows with &lt;code&gt;yield&lt;/code&gt; as the cursor brings them:&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;MongoDocumentItemsReadGateway&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;DocumentItemsReadGateway&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/** @return \Generator&amp;lt;DocumentItemView&amp;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;stream&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;$tenantId&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;$documentId&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;\Generator&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$cursor&lt;/span&gt; &lt;span class="o"&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;collection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'tenantId'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'documentId'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$documentId&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'projection'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'sku'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'quantity'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'price'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="s1"&gt;'sort'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;// stable order, with a unique tie-breaker&lt;/span&gt;
                &lt;span class="s1"&gt;'batchSize'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;batchSize&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;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cursor&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nc"&gt;DocumentItemView&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fromRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;The heavy fields, the ones only needed on small accounts or when opening the detail, are loaded conditionally depending on the size of the account. Separating reads and writes this way doesn't require a second database or event sourcing: the separation is logical.&lt;/p&gt;

&lt;h2&gt;
  
  
  Third change: streaming from the controller
&lt;/h2&gt;

&lt;p&gt;With an iterable in hand, the controller can start responding without waiting to have all the rows. In Symfony this is done with a &lt;a href="https://symfony.com/doc/current/components/http_foundation.html#streaming-a-response" rel="noopener noreferrer"&gt;&lt;code&gt;StreamedResponse&lt;/code&gt;&lt;/a&gt; that writes one line of JSON per item (NDJSON) and flushes:&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="na"&gt;#[Route('/documents/{documentId}/items', methods: ['GET'])]&lt;/span&gt;
&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ListDocumentItemsController&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;__construct&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;QueryBus&lt;/span&gt; &lt;span class="nv"&gt;$queryBus&lt;/span&gt;&lt;span class="p"&gt;)&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;__invoke&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;$documentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;TenantContext&lt;/span&gt; &lt;span class="nv"&gt;$tenant&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;StreamedResponse&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$items&lt;/span&gt; &lt;span class="o"&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;queryBus&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ask&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;ListDocumentItemsQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tenant&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nv"&gt;$documentId&lt;/span&gt;&lt;span class="p"&gt;),&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StreamedResponse&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="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$items&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="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$items&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;JSON_THROW_ON_ERROR&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// one row per line&lt;/span&gt;

                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nb"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// sent in batches, not row by row&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;

                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;connection_aborted&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// the client has gone, we stop reading&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="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Content-Type'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'application/x-ndjson'&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;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'X-Accel-Buffering'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'no'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// nginx must not buffer the response&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$response&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;Two details matter. The first, &lt;a href="https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering" rel="noopener noreferrer"&gt;&lt;code&gt;X-Accel-Buffering: no&lt;/code&gt;&lt;/a&gt;: without it, nginx buffers the response and the client receives nothing until it finishes, and the streaming is pointless. The second, that &lt;a href="https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_read_timeout" rel="noopener noreferrer"&gt;nginx's read timeout is measured between two reads, not over the whole response&lt;/a&gt;: as long as rows keep arriving, it doesn't trigger. Symfony also ships &lt;a href="https://symfony.com/doc/current/components/http_foundation.html#streaming-a-json-response" rel="noopener noreferrer"&gt;&lt;code&gt;StreamedJsonResponse&lt;/code&gt;&lt;/a&gt;, which accepts generators; here I use NDJSON because the frontend reads the stream line by line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fourth change: React renders with the first results
&lt;/h2&gt;

&lt;p&gt;The other side is making the frontend not wait. To read the stream, an async generator that splits the lines as they arrive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;streamItems&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;documentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// In the real frontend this sits behind a repository, with the whole DDD layer; here it goes straight to fetch to keep it simple&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/documents/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;documentId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/items`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`HTTP &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipeThrough&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;TextDecoderStream&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;getReader&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;value&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;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// the last line may arrive half-finished&lt;/span&gt;

    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;DocumentItem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;And to hook it up to &lt;code&gt;useQuery&lt;/code&gt;, TanStack Query's &lt;a href="https://tanstack.com/query/latest/docs/framework/react/reference/functions/experimental_streamedQuery" rel="noopener noreferrer"&gt;&lt;code&gt;streamedQuery&lt;/code&gt;&lt;/a&gt;: it accumulates the chunks that arrive into an array. The query is &lt;code&gt;pending&lt;/code&gt; until the first one arrives, moves to &lt;code&gt;success&lt;/code&gt; at that moment, and stays &lt;code&gt;fetching&lt;/code&gt; until the stream closes.&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;queryOptions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useQuery&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;experimental_streamedQuery&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;streamedQuery&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;@tanstack/react-query&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;documentItemsQuery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;documentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;queryOptions&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;queryKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;document-items&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;documentId&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;queryFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;streamedQuery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;streamFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;signal&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;streamItems&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;documentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;DocumentItemsList&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;documentId&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;documentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isFetching&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;documentItemsQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;documentId&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ListSkeleton&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ItemsTable&lt;/span&gt; &lt;span class="na"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isFetching&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Loading more…&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;streamedQuery&lt;/code&gt; is still marked as experimental in TanStack Query, so its API may change.&lt;/p&gt;

&lt;p&gt;For the person using the screen, the difference is huge: they see the first item almost immediately, instead of having to wait 40 seconds to see it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to watch out for with streaming
&lt;/h2&gt;

&lt;p&gt;While rows are arriving the total isn't known yet, so the screen shouldn't show an "of N" or an invented percentage. The order has to be stable, with a unique tie-breaker, or a row can appear twice or be skipped. And it's better to send the rows in batches, not one by one, because with tens of thousands of rows rendering each one separately eats the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary of the changes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;What it removes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Batches&lt;/td&gt;
&lt;td&gt;Memory growing with the size of the account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A view instead of the aggregate&lt;/td&gt;
&lt;td&gt;Hydrating the full aggregate of every item&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Streaming from the controller&lt;/td&gt;
&lt;td&gt;Waiting for all the rows before responding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;React with &lt;code&gt;streamedQuery&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Waiting for everything to arrive before rendering&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Result
&lt;/h2&gt;

&lt;p&gt;From timeout to about 10 seconds, with the screen filling up from the first results. With that you can get into accounts with more than 100,000 products.&lt;/p&gt;

&lt;p&gt;Ten seconds is not fast, but before it didn't open.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same thing, at another scale
&lt;/h2&gt;

&lt;p&gt;In the webhook pipeline, which I cover in &lt;a href="https://0311b.com/en/blog/duplicated-webhooks/" rel="noopener noreferrer"&gt;Why your webhooks arrive duplicated&lt;/a&gt;, I follow the same rule. I use Datadog's APM and traces to see which parts of the flow are really slow, and I aim the optimisation there, not where it seems. The traces are sampled and are useful for performance; it's also worth measuring how long each message waits in the queue, because sometimes the slow part is the wait and not the code.&lt;/p&gt;

&lt;p&gt;Before optimising, measure.&lt;/p&gt;

</description>
      <category>php</category>
      <category>symfony</category>
      <category>react</category>
      <category>performance</category>
    </item>
    <item>
      <title>From a Slack thread to a pull request: an agent that reproduces the incident</title>
      <dc:creator>Ricardo Morato Mateos</dc:creator>
      <pubDate>Sun, 13 Sep 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/0311b/from-a-slack-thread-to-a-pull-request-an-agent-that-reproduces-the-incident-1nj7</link>
      <guid>https://dev.to/0311b/from-a-slack-thread-to-a-pull-request-an-agent-that-reproduces-the-incident-1nj7</guid>
      <description>&lt;p&gt;A customer incident usually starts with a support message in Slack: this store had this happen with an order. And from there the path is almost always the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  The manual path
&lt;/h2&gt;

&lt;p&gt;Find the pipeline logs, reconstruct which webhooks arrived and in what order, understand what happened, write a test that reproduces it and, finally, fix it. Almost all of it is mechanical, and the only thing that changes from one incident to the next is the case. That's why I built an agent that walks that path.&lt;/p&gt;

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

&lt;p&gt;Starting from the Slack thread:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It retrieves the pipeline logs.&lt;/li&gt;
&lt;li&gt;It reconstructs the real sequence of webhooks we received from the store.&lt;/li&gt;
&lt;li&gt;It generates a test that reproduces the case.&lt;/li&gt;
&lt;li&gt;It proposes the fix in a pull request.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When reconstructing the sequence you have to look at two clocks: the order in which each webhook arrived and the date on which the provider emitted it. Many incidents are exactly the difference between the two; I cover the layers I use to prevent them in &lt;a href="https://0311b.com/en/blog/duplicated-webhooks/" rel="noopener noreferrer"&gt;Why your webhooks arrive duplicated&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  First the test, then the fix
&lt;/h2&gt;

&lt;p&gt;The order matters. In my area, every incident comes in with a test that stops it from happening again, so the agent doesn't start by fixing: it starts by reproducing.&lt;/p&gt;

&lt;p&gt;A test that fails for the right reason is the proof that you have reproduced the problem, and reproducing it is the first step to understanding it. If the agent can't reproduce the case, there is nothing to fix yet. Sometimes the cause is that a piece of data is missing: a webhook that never arrived or an external result that was left in doubt. That is also a useful result, and it's better to know it before it proposes a blind patch.&lt;/p&gt;

&lt;p&gt;For a webhook incident, the test has to be deterministic: the events in the order in which they happened, a fixed clock and fakes that respect the same constraints as production, like uniqueness and versions, because a fake without them makes a concurrency test lie. The payloads come from real cases, but they have to be scrubbed before they are left in the repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  The panel that already existed
&lt;/h2&gt;

&lt;p&gt;Before this I had built another piece in the same direction: an operations panel for the webhook pipeline, so that Customer Support can diagnose a case without escalating it to engineering. You search by store, provider and event type, you see the processing state live, and each order has its timeline with all its events chained by correlation ID.&lt;/p&gt;

&lt;p&gt;Logs explain what a process did, but they can be sampled or have expired. The current state and its history of transitions live in a durable entity, and that's where it's best to read what happened. Logs and traces point to that state, they don't replace it.&lt;/p&gt;

&lt;p&gt;It's the same idea from two sides: making the path between "something has failed" and "I know exactly what happened" short.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>automation</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
