<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>yaodong.dev</title><description>Personal blog of Yaodong Zhao</description><link>https://yaodong.dev/</link><item><title>The Bottleneck Is the Process</title><link>https://yaodong.dev/the-bottleneck-is-the-process/</link><guid isPermaLink="true">https://yaodong.dev/the-bottleneck-is-the-process/</guid><description>AI made every step faster but left the connections between them untouched. You cannot simply strip the process away, because it carries real weight. The harness has not disappeared. It moved inward to individuals, and across to whoever picks up the new capability.</description><pubDate>Sun, 29 Mar 2026 14:40:29 GMT</pubDate><content:encoded>&lt;h2 id=&quot;the-paradox&quot;&gt;The paradox&lt;/h2&gt;
&lt;p&gt;AI made every node in the software pipeline faster. But the pipeline itself did not speed up.&lt;/p&gt;
&lt;p&gt;In a controlled experiment by GitHub, developers using Copilot completed a coding task 55 percent faster than those without it.&lt;sup&gt;&lt;a href=&quot;#user-content-fn-1&quot; id=&quot;user-content-fnref-1&quot; data-footnote-ref aria-describedby=&quot;footnote-label&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; At the organizational level, Faros AI’s study of over 10,000 developers found that teams with high AI adoption completed 21 percent more tasks and merged 98 percent more pull requests, but code review times increased by 91 percent.&lt;sup&gt;&lt;a href=&quot;#user-content-fn-2&quot; id=&quot;user-content-fnref-2&quot; data-footnote-ref aria-describedby=&quot;footnote-label&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; And in a randomized controlled trial by METR, sixteen experienced open-source developers completed 246 tasks with AI tools and were 19 percent slower. Before the experiment, they predicted AI would speed them up by 24 percent. Afterward, they still believed it had.&lt;sup&gt;&lt;a href=&quot;#user-content-fn-3&quot; id=&quot;user-content-fnref-3&quot; data-footnote-ref aria-describedby=&quot;footnote-label&quot;&gt;3&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;
&lt;p&gt;Something is absorbing the gains. The bottleneck is the process itself: the handoffs, reviews, build cycles, and approval gates that sit between the nodes. But you cannot simply remove them, because they carry real weight. The work is to understand what they carry and where it went.&lt;/p&gt;
&lt;h2 id=&quot;why-the-harness-exists&quot;&gt;Why the harness exists&lt;/h2&gt;
&lt;p&gt;Every software engineer knows the rhythm. A product manager arrives with wireframes. Engineers ask clarifying questions, push back. The wireframes are revised. A technical design document is drafted, debated, finalized. Only then does anyone write code. And once the code is written, it enters a review queue, passes through QA, and, weeks after the original idea, reaches production.&lt;/p&gt;
&lt;p&gt;This process evolved over decades, shaped by a basic reality: building software is expensive, and the people building it can only hold so much in their heads at once. The product manager understands users but not system architecture. The engineer understands architecture but not market context. The process connecting them bridges these gaps.&lt;/p&gt;
&lt;p&gt;This is not so different from what AI engineers now call a harness: the scaffolding you place around an imperfectly reliable executor. Guardrails, validation layers, checkpoints. You build it not because the executor is useless, but because it is powerful and fallible at the same time.&lt;/p&gt;
&lt;p&gt;Each node in the traditional pipeline is a control point where someone checks that the work so far is sound before it moves forward. Some bridge cognitive gaps: a design review validates feasibility, a code review catches logical errors. Others exist for institutional reasons: compliance, accountability, alignment. AI will reshape each of these differently, because they exist for different reasons.&lt;/p&gt;
&lt;h2 id=&quot;the-nodes-got-faster-the-seams-did-not&quot;&gt;The nodes got faster, the seams did not&lt;/h2&gt;
&lt;p&gt;AI changed what a single person can do. A product manager with access to AI can generate a working prototype rather than drawing static wireframes. An engineer can explore user research, analyze feedback data, produce documentation, all without waiting for a specialist. The boundaries between roles have started to blur.&lt;/p&gt;
&lt;p&gt;But the friction is not in the nodes. I run multiple AI agents in parallel using tmux and a set of custom tools. Code generation has scaled dramatically. But my team’s process has not changed: the code still goes through Jenkins, the build is slow, and it often fails. Fixing a failing test means editing, committing, rebuilding, and waiting again. If another test breaks, the loop repeats. Code review is still manual. QA is still manual. The bottleneck is not just that I can produce code faster than the pipeline can absorb it. It is that the pipeline contains cycles, and each cycle runs at the old speed.&lt;/p&gt;
&lt;p&gt;The process was built for a different tempo. AI changed the nodes but left the interfaces intact.&lt;/p&gt;
&lt;h2 id=&quot;what-happens-when-you-try-to-remove-them&quot;&gt;What happens when you try to remove them&lt;/h2&gt;
&lt;p&gt;The natural response is to strip away the process. If the connections between steps are the bottleneck, get rid of them. Less oversight, fewer checkpoints, a leaner pipeline.&lt;/p&gt;
&lt;p&gt;The instinct is understandable, but it misreads where the weight lives.&lt;/p&gt;
&lt;h3 id=&quot;the-output-is-less-reliable-than-it-looks&quot;&gt;The output is less reliable than it looks&lt;/h3&gt;
&lt;p&gt;CodeRabbit’s analysis of 470 open-source pull requests found that AI-generated code introduces 1.7 times more issues than human-written code, with security vulnerabilities increasing by roughly 2.7 times.&lt;sup&gt;&lt;a href=&quot;#user-content-fn-4&quot; id=&quot;user-content-fnref-4&quot; data-footnote-ref aria-describedby=&quot;footnote-label&quot;&gt;4&lt;/a&gt;&lt;/sup&gt; Most AI-generated code I see has issues of some kind. And as models get smarter, the issues get subtler, harder to catch in review, more likely to reach production unnoticed. Shipping AI-generated code without rigorous validation is not a streamlined workflow. It is negligence with extra steps.&lt;/p&gt;
&lt;h3 id=&quot;the-work-found-new-owners&quot;&gt;The work found new owners&lt;/h3&gt;
&lt;p&gt;On a side project where I had no designer, I used AI to generate UI components. It worked, until I needed consistency across the product. The AI produced screens that each looked good on their own but belonged to different applications. So I built a design system: color tokens, typography scales, spacing rules, reusable components, all documented thoroughly enough that the AI could follow them. This was not a shortcut around design discipline. It was design discipline, carried by someone who was never a designer.&lt;/p&gt;
&lt;p&gt;This pattern runs in every direction. A product manager with AI can generate a prototype and discuss it directly with customers, skipping the cycle of handing requirements to a designer, waiting for mockups, and cycling back through feedback. Designers are crossing into engineering: Jenny Wen, who leads design for Claude at Anthropic, describes spending a growing share of her time writing code, polishing implementations, and pairing directly with engineers, work that used to sit entirely on the engineering side.&lt;sup&gt;&lt;a href=&quot;#user-content-fn-5&quot; id=&quot;user-content-fnref-5&quot; data-footnote-ref aria-describedby=&quot;footnote-label&quot;&gt;5&lt;/a&gt;&lt;/sup&gt; The roles blur, but the discipline of each role follows whoever picks up the work.&lt;/p&gt;
&lt;p&gt;AI does not just blur roles. It moves the cognitive work of each role to whoever picks up the new capability. The harness did not get lighter. It found new owners.&lt;/p&gt;
&lt;h3 id=&quot;the-work-moved-inward&quot;&gt;The work moved inward&lt;/h3&gt;
&lt;p&gt;The migration runs deeper than role boundaries. Even within a single role, the cognitive work that the process used to carry did not vanish. It moved inward.&lt;/p&gt;
&lt;p&gt;In the traditional model, the harness lived between people: the handoff from PM to engineering, the pull request review, the QA gate. Quality was a collective responsibility, distributed across roles and enforced through process.&lt;/p&gt;
&lt;p&gt;In the AI-augmented model, much of that work moves inside the individual. The PM who once needed an engineer to assess feasibility now generates a prototype directly, but must judge its soundness alone. The engineer who once relied on a QA team now works with an AI that writes tests, but must decide whether those tests are meaningful.&lt;/p&gt;
&lt;p&gt;This is what the METR results show. Those experienced developers were not failing to use AI. They were doing what the new model demands: spending more time evaluating, testing, and reworking AI output than they saved by generating it. They were building the harness inside their own heads.&lt;/p&gt;
&lt;p&gt;If this pattern holds, the implication is clear: junior and mid-level engineers use AI to produce more output, and the verification cost flows upward to the senior engineers who can judge whether that output is sound. The harness does not distribute evenly. It concentrates on the people who can bear it.&lt;/p&gt;
&lt;h2 id=&quot;the-trail-went-cold&quot;&gt;The trail went cold&lt;/h2&gt;
&lt;p&gt;There is a subtler loss. Traditional workflows produced institutional knowledge as a byproduct. Design documents recorded not just what was built, but why. Pull request discussions preserved architectural reasoning. When someone moves from idea to implementation in an afternoon with AI, that process no longer runs. Six months later, someone finds working code with no record of the constraints it was designed around, and no way to tell whether a strange choice was deliberate or an artifact of how the AI generated it.&lt;/p&gt;
&lt;p&gt;The old process generated knowledge because it was slow enough to produce it. The new process must generate it on purpose.&lt;/p&gt;
&lt;h2 id=&quot;redesigning-the-seams&quot;&gt;Redesigning the seams&lt;/h2&gt;
&lt;p&gt;The old interfaces were load-bearing, but they were built for a tempo that no longer exists. Keeping them unchanged wastes AI’s capacity. Removing them loses what they carried. The work is to rebuild them for the new pace.&lt;/p&gt;
&lt;p&gt;Each person working with AI needs their own harness, and “be more careful” is not one. A real harness has two parts. The first is evaluation: making verification automatic rather than discretionary, through static analysis, sandboxed environments, evaluation suites, and traceability that marks which code came from AI. The difference from the old process is not speed. It is that evaluation exists as tooling, not as a mental note. The second is constraint: giving the AI a framework that makes its output consistent before you need to evaluate it. Architectural standards, naming conventions, interface contracts, design systems. These narrow the space of what the AI can generate, so consistency comes from structure rather than inspection. The constraint framework has to exist before the AI runs, or the output drifts.&lt;/p&gt;
&lt;p&gt;Organizations that invest in AI code generation without equally investing in AI code evaluation will produce more output, but will not be able to tell whether that output is sound.&lt;/p&gt;
&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;The harness has always existed. It has changed address. In some cases, it moved inward: individuals now carry verification work that used to be distributed across a process. In other cases, it moved across: people who were never designers or data scientists now carry the disciplinary weight of those roles, because AI gave them the capability without giving them the experience.&lt;/p&gt;
&lt;p&gt;Evaluation pipelines, constraint frameworks, and knowledge capture are not overhead from an old way of working. They are the infrastructure of the new one. And because AI can replicate a senior engineer’s process but not a senior engineer’s judgment, the people who can tell sound output from plausible output matter more now, not less.&lt;/p&gt;
&lt;p&gt;The bottlenecks will keep shifting. Identifying where the friction lives and redesigning the process around it is continuous work, because the tempo is still changing.&lt;/p&gt;
&lt;hr&gt;
&lt;section data-footnotes class=&quot;footnotes&quot;&gt;&lt;h2 class=&quot;sr-only&quot; id=&quot;footnote-label&quot;&gt;Footnotes&lt;/h2&gt;
&lt;ol&gt;
&lt;li id=&quot;user-content-fn-1&quot;&gt;
&lt;p&gt;GitHub, &lt;a href=&quot;https://github.blog/news-insights/research/research-quantifying-github-copilots-impact-on-developer-productivity-and-happiness/&quot;&gt;Research: Quantifying GitHub Copilot’s Impact on Developer Productivity and Happiness&lt;/a&gt;, 2022. &lt;a href=&quot;#user-content-fnref-1&quot; data-footnote-backref=&quot;&quot; aria-label=&quot;Back to reference 1&quot; class=&quot;data-footnote-backref&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&quot;user-content-fn-2&quot;&gt;
&lt;p&gt;Faros AI, &lt;a href=&quot;https://www.faros.ai/blog/ai-software-engineering&quot;&gt;The AI Productivity Paradox&lt;/a&gt;, 2025. &lt;a href=&quot;#user-content-fnref-2&quot; data-footnote-backref=&quot;&quot; aria-label=&quot;Back to reference 2&quot; class=&quot;data-footnote-backref&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&quot;user-content-fn-3&quot;&gt;
&lt;p&gt;METR, &lt;a href=&quot;https://metr.org/blog/2025-07-10-early-2025-ai-experienced-os-dev-study/&quot;&gt;Measuring the Impact of Early-2025 AI on Experienced Open-Source Developer Productivity&lt;/a&gt;, July 2025. &lt;a href=&quot;#user-content-fnref-3&quot; data-footnote-backref=&quot;&quot; aria-label=&quot;Back to reference 3&quot; class=&quot;data-footnote-backref&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&quot;user-content-fn-4&quot;&gt;
&lt;p&gt;CodeRabbit, &lt;a href=&quot;https://www.coderabbit.ai/blog/state-of-ai-vs-human-code-generation-report&quot;&gt;State of AI vs Human Code Generation Report&lt;/a&gt;, December 2025. &lt;a href=&quot;#user-content-fnref-4&quot; data-footnote-backref=&quot;&quot; aria-label=&quot;Back to reference 4&quot; class=&quot;data-footnote-backref&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&quot;user-content-fn-5&quot;&gt;
&lt;p&gt;Anthropic Engineering, &lt;a href=&quot;https://www.anthropic.com/engineering/harness-design-long-running-apps&quot;&gt;Harness Design for Long-Running Application Development&lt;/a&gt;, 2025. &lt;a href=&quot;#user-content-fnref-5&quot; data-footnote-backref=&quot;&quot; aria-label=&quot;Back to reference 5&quot; class=&quot;data-footnote-backref&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;
</content:encoded></item><item><title>Push It Until It Breaks</title><link>https://yaodong.dev/push-it-until-it-breaks/</link><guid isPermaLink="true">https://yaodong.dev/push-it-until-it-breaks/</guid><description>You can&apos;t find the boundary of a tool without crossing it. Push too far, watch it fail, recalibrate. The models keep changing. The tools keep changing. So I keep pushing.</description><pubDate>Sun, 01 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A year ago I was accepting tab completions. Now AI is in every stage of my development workflow, from problem definition to deployment. The shift didn’t happen in a weekend. It happened through a repeating cycle: push the tool too far, watch it fail, recalibrate. Then something changes, a new model, a better tool, a different workflow, a new category of problem, and I push again.&lt;/p&gt;
&lt;p&gt;I work on production systems. The kind with years of accumulated decisions, implicit constraints, and code that nobody fully understands anymore. This is not a story about building something new in three days. It’s about what happens when you bring AI into a codebase that fights back.&lt;/p&gt;
&lt;h2 id=&quot;the-overuse-cycle&quot;&gt;The overuse cycle&lt;/h2&gt;
&lt;p&gt;You can’t find the boundary of a tool without crossing it. My method, if you can call it that, is deliberate overuse. Every time I get access to a new model or a meaningful upgrade, I push it past what I think it can handle. I let it take on tasks I’m not sure it’s ready for. It fails. I learn something specific about where it breaks. I recalibrate to a level I can deliver at. Then something changes, a new model, a better tool, a different approach, a new category of problem, and I push again.&lt;/p&gt;
&lt;p&gt;In mid-2025, I tried delegating legacy refactoring to AI. It would confidently rename internal methods, then call the old names elsewhere in the codebase. It would invent helper functions that didn’t exist. On complex tasks, the time I spent fixing AI’s mistakes ate into the time I was supposed to be saving, so I dialed it back. When Opus 4.5 dropped in late November 2025, I tried the same category of task again. The model handled the renames correctly, and it caught something an IDE wouldn’t: mock annotations in pytest that still referenced the old names as strings. Three months later, Opus 4.6 pushed it further. It could read through legacy code and answer detailed questions about behavior that used to require digging through years of commits. Each cycle teaches me something the previous one couldn’t, because the tool is genuinely different from the one I calibrated against last time.&lt;/p&gt;
&lt;h2 id=&quot;make-your-codebase-legible&quot;&gt;Make your codebase legible&lt;/h2&gt;
&lt;p&gt;That cycle taught me something else: more often than not, the failures weren’t about model capability. They were about what I was feeding the model.&lt;/p&gt;
&lt;p&gt;The single most important thing I’ve learned is that the real work of AI-assisted development is making your codebase legible to the AI. People have started calling this context engineering. For me, it came down to something concrete: the quality of AI output had less to do with how I asked and more to do with what I gave the model to work with. Documentation, code structure, architectural constraints, type systems, tool definitions, conversation history. All of it matters more than the prompt.&lt;/p&gt;
&lt;p&gt;In practice, this means clear module boundaries so the AI knows where one concern ends and another begins. It means typed interfaces so it can reason about contracts without reading every implementation. Documentation maintained as infrastructure, not an afterthought. Architectural constraints enforced mechanically by linters and CI, not by convention that the AI can’t see.&lt;/p&gt;
&lt;p&gt;I’ve started evaluating every task through this lens. When I look at a piece of work, I’m asking: what context does this need, where does it live, and how hard is it to gather? A new feature is usually straightforward because the context has natural boundaries: a PRD, a design spec, an API doc. But troubleshooting a subtle bug in a business workflow is a completely different situation. The context is implicit, scattered across code paths, old Slack threads, and the heads of people who built the system. You can’t hand that to AI. Not yet, anyway. You have to drive the conversation yourself, digging until you understand what’s actually going on.&lt;/p&gt;
&lt;h2 id=&quot;build-context-before-writing-code&quot;&gt;Build context before writing code&lt;/h2&gt;
&lt;p&gt;So the question becomes: how do you actually build that context in practice?&lt;/p&gt;
&lt;p&gt;Before touching any code, I create a document in the module’s docs folder, pull in whatever context exists (the PRD, design mockups, relevant discussions), and have AI generate a first draft of its understanding of the existing codebase. This document becomes the foundation for everything that follows.&lt;/p&gt;
&lt;p&gt;I don’t dump everything into this document at once. I expose context to the AI progressively. First, I give it the broad shape of the problem and let it explore the code on its own, then show me what it understands. If the understanding is off, I correct it before adding more detail. If it’s on track, I feed in the next layer: specific technical constraints, relevant code paths, edge cases. Each round, I’m checking whether the AI’s mental model matches reality before going deeper. Dumping all context upfront sounds efficient but it isn’t. The AI loses the thread, and I lose my ability to catch misunderstandings early.&lt;/p&gt;
&lt;h2 id=&quot;distill-noisy-conversations-into-clean-documents&quot;&gt;Distill noisy conversations into clean documents&lt;/h2&gt;
&lt;p&gt;That progressive approach works well for building up understanding. But some problems don’t build up cleanly; they meander.&lt;/p&gt;
&lt;p&gt;As AI and I investigate a problem together, the conversation accumulates noise: wrong hypotheses, dead ends, tangential explorations. At a certain point, I have AI write down its current understanding as a clean document. Then I start a new conversation from that document alone. The new session inherits the conclusions without the noise.&lt;/p&gt;
&lt;p&gt;I did this when refactoring a file processing feature built on a decade of legacy code. The processing pipeline was long and tangled, and the first conversation with AI was mostly us feeling our way through it: wrong assumptions, backtracking, gradually piecing together how the pieces fit. After a few rounds of distillation, what started as a messy investigation became a document covering the feature from its overall architecture down to every edge case. I checked it into the codebase. It was the first time that subsystem had any real documentation.&lt;/p&gt;
&lt;h2 id=&quot;dont-let-ai-write-code-without-a-plan&quot;&gt;Don’t let AI write code without a plan&lt;/h2&gt;
&lt;p&gt;Whether I’m building context from scratch or distilling it from a messy investigation, the output is the same: a shared understanding of the problem. But understanding the problem isn’t the same as agreeing on a solution.&lt;/p&gt;
&lt;p&gt;I don’t let AI write code until I’ve reviewed and approved a plan. I learned this the hard way: AI charging ahead and producing code that worked in isolation but broke the surrounding system. The most expensive failure mode isn’t bad syntax. It’s an implementation that looks correct but misunderstands the system it lives in. A good plan prevents that.&lt;/p&gt;
&lt;p&gt;My process is progressive review. AI generates a design document, which can be long. I don’t read it end to end. I ask for a summary of the overall approach first. If the direction is wrong, I correct it before going deeper. If it’s sound, I drill into specific technical decisions: how does it handle concurrency? What’s the migration path? What happens at the boundary with the existing auth system?&lt;/p&gt;
&lt;p&gt;This is where experience pays off most. AI gets the broad architecture right more often than not. But it regularly misses things that require knowledge it can’t access: our team has a limited engineering budget for this quarter, so the migration approach needs to be simpler. Or it uses the wrong pattern for parallel execution in AWS Step Functions, something that compiles fine but won’t behave correctly given our existing infrastructure.&lt;/p&gt;
&lt;p&gt;After the iterative discussion, I read the full design. By then most of it is already familiar, and the final pass goes quickly.&lt;/p&gt;
&lt;h2 id=&quot;ask-what-it-would-do-without-your-constraints&quot;&gt;Ask what it would do without your constraints&lt;/h2&gt;
&lt;p&gt;After arriving at a design within the constraints of our existing system, I ask AI to throw away those constraints and tell me what it would do from scratch.&lt;/p&gt;
&lt;p&gt;Most of the time I stick with the constrained version. But not always.&lt;/p&gt;
&lt;p&gt;We were designing a concurrency control mechanism. The existing approach worked but didn’t make sense: it called AWS APIs to list running executions on the fly, which was fragile and slow. When I asked for the unconstrained version, AI proposed a lock in DynamoDB, similar to how Terraform handles state locking. I recognized the pattern as battle-tested. It pushed us slightly beyond our original scope, but the benefits were strong enough to justify the extra work. We went with the unconstrained design.&lt;/p&gt;
&lt;p&gt;The trick is that you’re triangulating. The constrained design shows you what’s practical. The unconstrained design shows you what’s structurally clean. Sometimes the distance between the two is smaller than you assumed.&lt;/p&gt;
&lt;h2 id=&quot;your-experience-is-also-a-trained-model&quot;&gt;Your experience is also a trained model&lt;/h2&gt;
&lt;p&gt;That kind of triangulation also works on your own instincts, though it’s harder to see in the moment.&lt;/p&gt;
&lt;p&gt;I was refactoring our product’s AI chat system. Business logic had grown into the core conversation flow over time. Every topic needed its own parameter checks and combinations, creating hard dependencies across features. The core should have been business-logic-agnostic. It wasn’t.&lt;/p&gt;
&lt;p&gt;AI recommended a clean separation: extract the business logic, rewrite the core as a generic pipeline. I thought that was too aggressive. My instinct was to take an incremental path. Unify the scattered parameters first, then gradually decouple.&lt;/p&gt;
&lt;p&gt;I attempted the incremental approach three times. Each time the coupling went deeper than I expected. The legacy code was too tangled for partial fixes. On the fourth attempt, I gave up on incrementalism and did what AI had originally suggested: a full rewrite of the core flow.&lt;/p&gt;
&lt;p&gt;It worked.&lt;/p&gt;
&lt;p&gt;AI didn’t carry my preference for gradual change. It just read the code structure and gave the structurally correct answer. That made me realize something: my engineering experience is also a trained model. Years of working on production systems have trained me toward incrementalism, risk aversion, certain architectural instincts. Those instincts are usually right. But they have blind spots, just like any model trained on a particular dataset. AI, trained on a different set of patterns, sometimes sees the structure more clearly precisely because it doesn’t carry my biases. I’m now more willing to take AI’s architectural suggestions seriously when they contradict my gut, and to ask myself whether my gut is seeing the current situation or just replaying a past one.&lt;/p&gt;
&lt;h2 id=&quot;use-models-against-each-other&quot;&gt;Use models against each other&lt;/h2&gt;
&lt;p&gt;If my own instincts have blind spots, it follows that any single model does too. So I use more than one.&lt;/p&gt;
&lt;p&gt;I use Claude Opus and OpenAI’s Codex on the same codebase and have them review each other. The natural checkpoint is the git commit: whenever changes are coherent enough to commit, I run the other model over the diff.&lt;/p&gt;
&lt;p&gt;Cross-model review catches real problems. In a side project, I removed a Rails credentials file and Claude helpfully cleaned up the corresponding .gitignore entry. Codex flagged that change: if someone on the team later used Rails credentials without realizing the ignore rule was gone, they could accidentally commit secrets to the repo. It was the kind of thing a second pair of eyes catches, not because one model is smarter, but because they have slightly different perspectives, like engineers with different backgrounds on the same team.&lt;/p&gt;
&lt;p&gt;Different models also have different architectural tendencies. One will over-split code into too many small abstractions. Another will over-engineer the overall structure. These tendencies shift with each model upgrade, which means the specific blind spots I’m compensating for keep changing too.&lt;/p&gt;
&lt;p&gt;After months of doing this, I have a sense of what cross-model review is good at: factual errors, knowledge gaps, inconsistencies. It’s less useful when two models genuinely disagree on an architectural question. That usually means I haven’t given them enough shared context to converge; the divergence is a signal about the context, not about which model is right. And when one model gets stuck on a problem, can’t fix a failing test no matter how many attempts, switching to the other often breaks through.&lt;/p&gt;
&lt;p&gt;There’s a deeper limitation. AI models are too easy to convince. If I explain my reasoning with any confidence, both models will agree. This means cross-review catches what I don’t know, but it probably won’t push back on what I’m wrong about. For that, you need a human colleague, or at minimum the discipline to ask AI to argue against your position before you ask it to confirm.&lt;/p&gt;
&lt;h2 id=&quot;control-dont-vibe&quot;&gt;Control, don’t vibe&lt;/h2&gt;
&lt;p&gt;None of this has made engineering easier. It’s made it different.&lt;/p&gt;
&lt;p&gt;The developers I know who get good results are doing the opposite of vibing. They’re controlling: reviewing plans, enforcing constraints, catching the things AI misses. The engineering skill hasn’t disappeared. It’s been redirected, from writing code to evaluating code, from implementing designs to judging designs, from solving problems to framing problems correctly. When AI handles execution, I have time to engage with context I never had bandwidth for before: why did the customer raise this issue, how did the PM arrive at this solution, is there a simpler framing everyone missed.&lt;/p&gt;
&lt;p&gt;I’ve gone back and forth on how much to invest in specific AI workflows. Most of what I learned six months ago is already outdated. But the time I spent making our codebase more legible, better typed, better documented? That compounds regardless of which model I use next. The specific tools don’t matter. What matters is whether you can look at a problem, break it into pieces that a machine can handle, and tell whether the output is actually correct.&lt;/p&gt;
&lt;p&gt;That skill isn’t going anywhere. Everything else is moving fast enough that I’m staying in the overuse cycle. Push, break, learn, push again.&lt;/p&gt;
</content:encoded></item><item><title>If They&apos;re Right: Preparing for the AI Economic Shock</title><link>https://yaodong.dev/if-they-are-right/</link><guid isPermaLink="true">https://yaodong.dev/if-they-are-right/</guid><description>What if AI works exactly as promised, and that&apos;s what breaks the economy? A scenario analysis explores how AI-driven productivity could hollow out the consumer economy, trigger a credit crisis, and reshape who benefits from growth.</description><pubDate>Sun, 22 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Most conversations about AI fall into one of two camps. The optimists talk about productivity, abundance, and a future where everyone benefits from machines doing the hard work. The doomers talk about existential risk, misalignment, and the end of human relevance. Both camps, in their own way, miss the most immediately important question.&lt;/p&gt;
&lt;p&gt;What if AI works exactly as the optimists hope, and that’s what breaks the economy?&lt;/p&gt;
&lt;p&gt;That’s the scenario laid out by macro researcher Citrini and investor Alap Shah in a recent collaboration. It’s written as a memo from June 2028, looking back at how a financial crisis unfolded. Not a story about AI going wrong. A story about AI going &lt;em&gt;right&lt;/em&gt;, and the economic system not being built to handle it.&lt;/p&gt;
&lt;a href=&quot;https://www.citriniresearch.com/p/2028gic&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot; class=&quot;link-card not-prose&quot;&gt;
&lt;span class=&quot;link-card-title&quot;&gt;The Global Intelligence Crisis&lt;/span&gt;
&lt;span class=&quot;link-card-description&quot;&gt;A scenario analysis written as a memo from June 2028, looking back at how AI-driven prosperity triggered a financial crisis.&lt;/span&gt;
&lt;span class=&quot;link-card-url&quot;&gt;www.citriniresearch.com&lt;/span&gt;
&lt;/a&gt;
&lt;p&gt;This isn’t a prediction. Neither Citrini nor Shah is claiming this is the base case. It’s a stress test of the left tail: the scenario that’s possible but underexplored. The mechanisms it describes are already visible, the exposure is real, and the cost of being unprepared is asymmetric.&lt;/p&gt;
&lt;p&gt;You don’t need to believe the 2028 crisis happens to find this useful. You just need to take it seriously enough to ask: &lt;em&gt;if it did, would I be okay?&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&quot;the-scenario-what-theyre-actually-saying&quot;&gt;The Scenario: What They’re Actually Saying&lt;/h2&gt;
&lt;p&gt;The story begins not with collapse, but with euphoria.&lt;/p&gt;
&lt;p&gt;By October 2026 in the scenario, the S&amp;amp;P 500 is flirting with 8,000 and Nasdaq has broken 30,000. AI is exceeding every expectation, productivity is booming at rates not seen since the 1950s, and corporate earnings are record-setting. The market is AI, and AI is working.&lt;/p&gt;
&lt;p&gt;The only problem: the economy is not.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Ghost GDP Problem&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The first crack isn’t visible in the headline numbers. Nominal GDP is printing mid-to-high single digits. Output per hour is surging. By every traditional measure, things look good. But something is wrong with how that output circulates.&lt;/p&gt;
&lt;p&gt;Citrini coins the term: &lt;em&gt;Ghost GDP&lt;/em&gt;. Output that shows up in the national accounts but never moves through the real economy. A GPU cluster in North Dakota can generate the economic output previously attributed to 10,000 white-collar workers in Manhattan, but it doesn’t pay rent in Manhattan. It doesn’t eat at the restaurant downstairs, buy clothes, take vacations, or carry a mortgage. The productivity is real. The circulation is gone.&lt;/p&gt;
&lt;p&gt;This is the central paradox. The consumer economy (70% of GDP) runs on human incomes. When the humans are replaced, the machines don’t pick up the spending. The velocity of money flatlines. The real economy withers even as the measured economy looks healthy.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Displacement Spiral&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;What makes this particularly dangerous is the absence of a natural brake. The feedback loop runs like this:&lt;/p&gt;
&lt;p&gt;AI capabilities improve → companies need fewer workers → white-collar layoffs increase → displaced workers spend less → margin pressure pushes firms to invest more in AI → AI capabilities improve.&lt;/p&gt;
&lt;p&gt;Each step is rational at the individual firm level. No one company can opt out. If your competitor cuts headcount and reinvests in AI, you either do the same or fall behind. The collective result is catastrophic. Every dollar saved on human labor funds the technology that makes the next round of cuts possible.&lt;/p&gt;
&lt;p&gt;The workers most affected aren’t factory floor workers or manual laborers. They’re the white-collar professionals whose incomes were the foundation of the $13 trillion mortgage market, the people underwriters considered “prime.” When their earning power deteriorates structurally, the assumptions baked into credit markets start to look shaky.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Three Waves&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The disruption spreads in waves, each one surprising the people who thought they were safe.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Wave one&lt;/em&gt; is the labor market. Expected, talked about, painful but contained. Or so it seems.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Wave two&lt;/em&gt; is software. Agentic coding tools reach a step-change capability in late 2025. A competent developer working with AI can now replicate the core functionality of a mid-market SaaS product in weeks. Not perfectly, but well enough that the CIO reviewing a $500,000 annual renewal starts asking “what if we just built this ourselves?” Pricing leverage evaporates. The long tail of SaaS gets hit hard. Then the “safe” systems of record follow. ServiceNow misses badly, cuts 15% of its workforce, and reveals something uncomfortable: the same AI-driven headcount reductions at its enterprise customers were mechanically destroying its own revenue base.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Wave three&lt;/em&gt; is intermediation, and this is the one most people don’t see coming. Fifty years of the American economy were built on human limitations: people forget to cancel subscriptions, accept bad prices to avoid more clicks, default to familiar brands out of laziness, trust a friendly face over a better deal. Trillions of dollars of enterprise value depended on those frictions persisting. AI agents don’t get tired. They don’t feel brand loyalty. They don’t have a home screen. Consumer agents begin re-shopping insurance renewals, negotiating SaaS contracts, eliminating travel booking markup, compressing real estate commissions from 2.5–3% to under 1%. What people called “relationships” in many of these industries was, it turns out, friction with a friendly face.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Reflexive Trap&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The cruelest part of the scenario is the corporate response. Faced with an existential threat, companies do the only rational thing available: cut headcount, redeploy the savings into AI, use AI to maintain output with lower costs. They become the most aggressive adopters of the very technology disrupting them.&lt;/p&gt;
&lt;p&gt;This is different from every historical disruption pattern. Kodak resisted. Blockbuster resisted. They died slowly. In this scenario, the incumbents don’t resist; they can’t afford to. But their rational individual responses create a collective doom loop. By November 2027, the crash arrives. By June 2028, unemployment is at 10.2% and the S&amp;amp;P is down 38% from its highs.&lt;/p&gt;
&lt;h2 id=&quot;the-mechanisms-already-running&quot;&gt;The Mechanisms Already Running&lt;/h2&gt;
&lt;p&gt;You don’t have to believe the 2028 timeline to take the mechanisms seriously. Most of what the scenario describes isn’t speculation about the future. It’s extrapolation of trends already visible today.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Ghost GDP Is Already Measurable&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The divergence between productivity and wage growth has been one of the defining economic puzzles of the last two decades, but the AI era is accelerating it in a specific way. Companies are reporting productivity gains while holding headcount flat or cutting it. Earnings growth isn’t translating into wage growth. The consumer economy looks strained despite GDP figures that appear healthy.&lt;/p&gt;
&lt;p&gt;The velocity of money has been in structural decline since the 2008 financial crisis. AI-driven replacement of white-collar roles doesn’t reverse that trend. It accelerates it. When a dollar of economic output is generated by a machine rather than a person, that dollar doesn’t flow back into rent, groceries, and consumer spending. The machine has no wallet.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;SaaS Pricing Pressure Is Already Here&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The build-vs-buy conversation is happening at Fortune 500 procurement departments right now, not in 2026. CIOs are already renegotiating contracts with leverage they didn’t have 18 months ago. The long tail of SaaS (tools that automate workflows but don’t own the underlying data) is already facing compression.&lt;/p&gt;
&lt;p&gt;The ServiceNow dynamic in the scenario isn’t hypothetical either. Any company that sells seats to white-collar workers and whose customers are actively cutting white-collar headcount has a mechanical revenue problem. As AI-driven workforce reduction spreads from tech to finance to professional services, the seat-count model faces structural headwinds regardless of how good the product is.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Intermediation and the Reflexive Loop&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The intermediation compression and the reflexive corporate loop described in the scenario are both already observable. Real estate commissions are compressing for the first time in decades, catalyzed by the 2024 NAR settlement and accelerated by AI-equipped buyers and sellers. Financial advice fees are under pressure from robo-advisors and AI-augmented platforms. Travel booking platforms are losing relevance as AI assistants plan and book end-to-end. Insurance, tax preparation, and routine legal work are all in the early stages of commoditization.&lt;/p&gt;
&lt;p&gt;Meanwhile, every round of tech layoffs partially funds the next generation of AI infrastructure. Every SaaS company under pricing pressure accelerates its AI feature development to justify its contract. Every bank cutting analyst headcount is simultaneously investing in AI research tools. The mechanisms are live. The question is only whether they reach crisis velocity.&lt;/p&gt;
&lt;h2 id=&quot;what-gets-destroyed&quot;&gt;What Gets Destroyed&lt;/h2&gt;
&lt;p&gt;The scenario is precise about what breaks first and why.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Businesses Built on Human Friction&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The broadest category of destruction is businesses whose value proposition depends on human limitations. Not businesses that serve humans, but businesses that &lt;em&gt;exploit&lt;/em&gt; the inefficiencies of being human. Subscription businesses that renew passively because cancellation is annoying. Pricing structures that persist because nobody shops five platforms before buying. Commission structures that survive because information asymmetry made the intermediary necessary. AI agents eliminate those frictions continuously, on behalf of the consumer, around the clock.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Middle of the Labor Market&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The scenario targets a specific band of workers: the white-collar professionals who process information, synthesize data, follow procedures, and produce outputs that can be described in a sufficiently detailed prompt. Junior analysts, paralegals, mid-level coders working on well-defined features, writers producing commodity content, managers whose primary job is coordinating human processes.&lt;/p&gt;
&lt;p&gt;This isn’t every job. But it’s a very large number of jobs, the backbone of the professional services economy, and it’s the income base the mortgage market was built on.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;SaaS Without Real Moats&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The distinction that matters isn’t “SaaS vs. not SaaS.” The real question is whether a product owns the data or merely processes it, whether it owns the relationship or merely facilitates it, whether it’s replaceable by a team with agentic coding tools and a few weeks.&lt;/p&gt;
&lt;p&gt;Workflow automation tools, lightweight project management, business intelligence with commodity data sources, integration middleware: these are the most exposed. Switching costs, build costs, and moats are all compressing at the same time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Credit Built on White-Collar Income&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;This is the mechanism that turns an economic slowdown into a financial crisis. Prime mortgages are underwritten on the assumption that white-collar income is stable and growing. If that assumption is structurally impaired, the $13 trillion mortgage market, and everything levered against it, gets repriced.&lt;/p&gt;
&lt;p&gt;It doesn’t require most white-collar workers to lose their jobs. It just requires enough uncertainty about white-collar income stability that underwriting assumptions break.&lt;/p&gt;
&lt;h2 id=&quot;what-survives&quot;&gt;What Survives&lt;/h2&gt;
&lt;p&gt;Not everything breaks. The scenario is specific enough about who gets hurt that it also implies who doesn’t.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Compute Layer&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The scenario is explicit: wealth concentrates at the GPU cluster level. The owners of compute see their wealth explode as labor costs vanish. This is the most direct hedge against the scenario. Not speculative AI software companies, but the actual picks-and-shovels: chips, hyperscaler infrastructure, and the energy required to run it.&lt;/p&gt;
&lt;p&gt;There’s an irony worth sitting with: the best hedge against AI-driven economic disruption is owning the AI. But that’s how the economics work.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Hard Assets and Physical Scarcity&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Things machines cannot replicate: land, energy infrastructure, ports, water rights, supply-constrained real estate. Hard assets have historically been the right place to be when returns to capital diverge sharply from returns to labor (as in the Gilded Age or the post-2008 decade). The scenario accelerates that divergence dramatically.&lt;/p&gt;
&lt;p&gt;There’s a supply-side logic here too. AI-driven construction or AI-optimized manufacturing can increase output efficiency, but it can’t create more land in San Francisco or more pipeline capacity in the Gulf. Physical scarcity is a durable moat in a way that software-based moats increasingly are not.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Judgment, Accountability, and High-Stakes Relationships&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The scenario reveals that conventional wisdom overestimated the durability of “relationships” in commodity intermediation. A real estate agent whose value was primarily information access gets disrupted. But it also implies, without quite saying it, that genuine high-stakes judgment is stickier.&lt;/p&gt;
&lt;p&gt;The surgeon making a complex call. The executive making a bet-the-company decision with incomplete information. The lawyer navigating a genuinely novel legal situation. The investor synthesizing macro, micro, and human context into a portfolio. These roles involve accountability, context, and judgment under uncertainty in ways that are harder to automate, not because AI lacks the pattern recognition, but because the accountability has to sit somewhere human.&lt;/p&gt;
&lt;p&gt;The key question to ask about any role: &lt;em&gt;if AI gets this wrong, who is responsible?&lt;/em&gt; Where that answer matters, where the consequences of error are significant and traceable, human judgment retains value.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;People Who Direct the Machines&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The most durable near-term position isn’t competing with AI; it’s directing it. The people who understand what agents can and can’t do, who can spec complex workflows, evaluate outputs, catch failure modes, and connect AI capability to real business problems, these people become substantially more productive, not less relevant.&lt;/p&gt;
&lt;p&gt;This isn’t “learn to prompt.” It’s developing genuine fluency with how AI systems work: their capabilities, their failure modes, their limits. The gap between someone who uses AI superficially and someone who understands it deeply is growing, not shrinking.&lt;/p&gt;
&lt;h2 id=&quot;what-to-actually-do&quot;&gt;What to Actually Do&lt;/h2&gt;
&lt;p&gt;Three tracks. Be honest about which one applies to you. Most people need all three in different proportions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Career: Audit Your Exposure&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The most useful exercise is uncomfortable but simple. Look at what you get paid for and ask: how much of this could be described as a detailed prompt?&lt;/p&gt;
&lt;p&gt;The higher that percentage, the more exposed you are. Not necessarily immediately (organizations move slowly, displacement takes time, and experience creates context AI doesn’t have). But the direction is set.&lt;/p&gt;
&lt;p&gt;The response isn’t panic. It’s migration. Move your work toward judgment, direction, and accountability. If you’re an analyst, the goal isn’t to analyze faster; it’s to develop the judgment layer above the analysis. If you’re a manager, the goal isn’t to coordinate processes more efficiently; it’s to make decisions the process can’t make. Get genuinely fluent with AI tools in your domain, not because it makes you look good, but because it changes what you can do and what you’re responsible for.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Capital: Stress-Test Your Portfolio&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;You don’t need to restructure everything. But running the scenario against your current holdings is a useful exercise.&lt;/p&gt;
&lt;p&gt;What in your portfolio depends on intermediation margins, white-collar income growth, or human inertia as a business model? What is exposed to SaaS multiples that assume ARR remains recurring? What is levered to credit structures built on professional income?&lt;/p&gt;
&lt;p&gt;On the other side: do you have any exposure to compute infrastructure, hard assets, or physical scarcity? Not as a speculation on AI winning, but as a hedge against the disruption scenario even partially unfolding.&lt;/p&gt;
&lt;p&gt;Increase liquidity more than feels comfortable. In disruption scenarios, optionality has asymmetric value. Distressed assets get cheap, and the people with cash available to act are the ones who benefit.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Mental Model: Shift from Earning to Owning&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The deepest implication of the scenario is a shift in the returns-to-capital vs. returns-to-labor ratio. If that shift is directionally correct even at a fraction of the scenario’s magnitude, the most important long-term move is migrating from being primarily a seller of labor to being an owner of productive assets, whether that’s equity, real assets, or intellectual property.&lt;/p&gt;
&lt;p&gt;This isn’t a new idea. But the scenario makes the urgency sharper. Building wealth through labor income is the dominant strategy of the professional class. If the value of cognitive labor deteriorates structurally, the people who are &lt;em&gt;only&lt;/em&gt; selling their time are the most exposed.&lt;/p&gt;
&lt;h2 id=&quot;the-asymmetry-that-matters&quot;&gt;The Asymmetry That Matters&lt;/h2&gt;
&lt;p&gt;To be clear about what this article is and isn’t: it’s not a prediction. Citrini and Shah weren’t making one either. The scenario might not happen. History does suggest that technological disruption creates new categories of work faster than it destroys old ones.&lt;/p&gt;
&lt;p&gt;But there’s something different about cognitive labor. Every prior wave of automation targeted physical tasks, leaving the cognitive premium intact. The Industrial Revolution, computerization, even early AI: all displaced hands while increasing the returns to minds. This time, the minds are in scope. Whether the historical pattern holds is a genuine open question, not a settled one.&lt;/p&gt;
&lt;p&gt;You don’t need certainty to act. You need asymmetry. The cost of stress-testing your exposure, building some liquidity, developing genuine AI fluency, and reorienting toward ownership is low. The cost of being wrong about income stability and business model durability is high.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;This article draws on scenario analysis by &lt;a href=&quot;https://www.citriniresearch.com/p/2028gic&quot;&gt;CitriniResearch&lt;/a&gt; and &lt;a href=&quot;https://alapshah1.substack.com/p/the-global-intelligence-crisis&quot;&gt;Alap Shah&lt;/a&gt;. Both are worth reading in full.&lt;/em&gt;&lt;/p&gt;
</content:encoded></item><item><title>The Multi-Agent Setup I Actually Use</title><link>https://yaodong.dev/the-multi-agent-setup-i-actually-use/</link><guid isPermaLink="true">https://yaodong.dev/the-multi-agent-setup-i-actually-use/</guid><description>One agent stretched across everything kept bleeding context between tasks. So I split it into three: an orchestrator, a coder, and a thinker, each in its own container, coordinated through Discord. Parallel work became possible for the first time.</description><pubDate>Sat, 21 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I kept running into the same problem. I’d be deep in a coding conversation, then need to switch to writing, and the agent would carry forward context that didn’t belong. Or I’d want two things happening at once and couldn’t, because everything ran through the same thread.&lt;/p&gt;
&lt;p&gt;What I actually wanted was one agent per domain. Not one assistant stretched across everything, but dedicated agents: one that lives in my codebase, one that goes deep on ideas, one that coordinates across the rest. Context isolation between them, too. Different topics, even within the same domain, shouldn’t bleed into each other.&lt;/p&gt;
&lt;p&gt;The solution I landed on was Discord. Its server, channel, and thread structure map naturally onto this: multiple agents, each in its own domain, with layered context isolation built into how the platform works.&lt;/p&gt;
&lt;p&gt;Last week I was in my &lt;code&gt;#brainstorming&lt;/code&gt; channel, thinking out loud with Helm, my primary agent, about a project idea. We got interested. I opened a thread, went deeper, and decided it was worth building. Helm wrote a handoff document summarizing what we’d discussed, created a &lt;code&gt;#project-hobby&lt;/code&gt; channel, and ran &lt;code&gt;gh repo create&lt;/code&gt; to set up the GitHub repo. Then she pinged Builder.&lt;/p&gt;
&lt;p&gt;Builder is my coding agent. She received the handoff document, fired up Claude Code, and started building. Partway through she hit a database problem and reported it to Helm. Helm spun up a PostgreSQL Docker container, resolved the environment issue, and handed control back. Builder kept going. When she had a working first version, Helm came in to review. Helm found a bug: users couldn’t log out. Builder opened a PR. Helm reviewed it, left a comment, approved it, and merged.&lt;/p&gt;
&lt;p&gt;While all of that was happening, I’d noticed something interesting about the problem we were solving, an insight that felt worth writing about. I went to my &lt;code&gt;#write-room&lt;/code&gt; channel and started thinking out loud. Explorer joined automatically. I laid out the core idea; she pushed back. Was I describing the tool, or the behavior change it enabled? That reframing opened up a better angle. She pulled in two related pieces I hadn’t seen, and we went back and forth on framing until something clicked. By the end of it, Explorer had shaped the conversation into a short piece and published it to my social accounts via Typefully.&lt;/p&gt;
&lt;p&gt;Both were happening at the same time. I wasn’t waiting for either.&lt;/p&gt;
&lt;img src=&quot;/assets/images/2026-02-21/parallel-work.png&quot; alt=&quot;Two Discord threads running simultaneously, Builder writing code on the left, Explorer developing an idea on the right&quot; width=&quot;1800&quot; height=&quot;1214&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;
&lt;p&gt;That’s the system in motion. Here’s what’s behind it: three agents, two Docker containers, one Discord server.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Helm&lt;/strong&gt; lives on the host machine and acts as the orchestrator. She has &lt;code&gt;requireMention: false&lt;/code&gt;, which means she sees every message in every channel. Not to respond to everything, but to stay aware. She’s the one who coordinates across the system: writing handoff documents, calling other agents, doing reviews, tracking what’s in flight.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Builder&lt;/strong&gt; runs in her own Docker container. She’s a coding agent with her own Linux environment, can install whatever tools she needs, and has Claude Code available. She only responds when explicitly @mentioned, either by me or by Helm. If her container crashes, nothing else goes down.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Explorer&lt;/strong&gt; also runs in Docker. Her personality is curiosity. She asks questions, pushes back, proposes angles I hadn’t considered. A conversation with her doesn’t feel like querying a tool; it feels like thinking out loud with someone who’s genuinely interested. She has a Typefully integration so when something is worth sharing, she can publish directly to my social accounts without leaving the conversation.&lt;/p&gt;
&lt;p&gt;OpenClaw’s memory system is built on plain Markdown files: &lt;code&gt;SOUL.md&lt;/code&gt; for personality, &lt;code&gt;USER.md&lt;/code&gt; for preferences, &lt;code&gt;MEMORY.md&lt;/code&gt; for long-term knowledge, and daily logs that accumulate. All of this loads into every session, growing more specific over time. Explorer and Builder started with minimal configuration; after a few weeks of regular use, their responses reflect the conversations we’ve had.&lt;/p&gt;
&lt;p&gt;This is also what makes OpenClaw feel different from using the same model in a chat interface. The underlying model is the same, but an agent with accumulated memory and a defined identity remembers what I care about, what I’ve tried, what didn’t work. I’ve found myself preferring to work in OpenClaw over a standard chat app, even for things that don’t require any of the multi-agent machinery.&lt;/p&gt;
&lt;p&gt;The Docker choice is the key architectural decision, and it’s worth understanding why. There are lighter alternatives: OpenClaw supports bindings, which let a single agent behave differently in different channels, with different personas, tools, and models. For many setups, that’s enough. But bindings are still one process. If it goes down, everything goes down. And because it’s a shared environment, any tool you give one “agent” is technically available to all of them.&lt;/p&gt;
&lt;p&gt;That trade-off didn’t work for me. Builder needs a rich development environment (runtimes, package managers, CLI tools) that Explorer and Helm have no use for. Giving all agents access to that environment isn’t just unnecessary, it’s messy. Docker gives each agent its own world: its own process, its own Linux environment, its own home folder mounted from the host. I can destroy and recreate Builder’s container entirely and she comes back exactly as she left off.&lt;/p&gt;
&lt;p&gt;There’s another dimension to this isolation that I didn’t anticipate when I started: version independence. OpenClaw moves fast. Breaking changes happen, and I’ve had agents fail to start after an upgrade more than once. Because each container manages its own OpenClaw installation, I can upgrade one agent without touching the others. If Builder breaks after an update, Helm is still running on a stable version. More importantly, I can ask Helm to go fix Builder: shell into the container, diagnose the issue, and restore her. That’s only possible because they don’t share a runtime.&lt;/p&gt;
&lt;img src=&quot;/assets/images/2026-02-21/architecture.png&quot; alt=&quot;System architecture: three agents connected through Discord, with Builder and Explorer in Docker containers and Helm on the host&quot; width=&quot;1800&quot; height=&quot;816&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;
&lt;p&gt;The agents are the moving parts. The Discord server is what makes them usable.&lt;/p&gt;
&lt;p&gt;Each channel is a domain. &lt;code&gt;#brainstorming&lt;/code&gt; is for thinking out loud. &lt;code&gt;#write-room&lt;/code&gt; is where Explorer lives. For engineering work, I create a dedicated channel per project, named &lt;code&gt;#project-xxx&lt;/code&gt;, rather than lumping everything into a generic &lt;code&gt;#dev&lt;/code&gt;. Builder passively follows these project channels; Helm only comes in when called. The thing I didn’t expect to matter so much is the channel topic. That topic is effectively a prompt. The topic for &lt;code&gt;#write-room&lt;/code&gt; describes Explorer’s style and approach. The topic for a project channel carries the codebase context Builder should assume, so the channel itself carries the instruction. I don’t have to repeat it every time I show up.&lt;/p&gt;
&lt;p&gt;When a conversation starts to go deep, I open a thread. This turned out to be the most useful structural decision. Each thread is its own isolated session. No context bleed between topics, no pollution back to the main channel. I can go deep on a problem, close the thread, and the channel is exactly as I left it. Threads auto-archive when they go quiet, so the visual space stays clean. They also inherit their configuration from the parent channel (mention policies, allowed agents, skills), so they work consistently without any extra setup.&lt;/p&gt;
&lt;p&gt;In practice, three kinds of channels emerged. Brainstorming channels are where unstructured ideas land. I open a thread for each new topic to keep conversations isolated. Project channels are for long-running work: when something from a brainstorming thread grows into a real project, it gets its own channel where Helm can push it forward. Automated channels handle recurring tasks, like a &lt;code&gt;#digest&lt;/code&gt; channel where content summaries arrive daily, or a &lt;code&gt;#heartbeat&lt;/code&gt; channel for monitoring.&lt;/p&gt;
&lt;img src=&quot;/assets/images/2026-02-21/discord-channels.png&quot; alt=&quot;Discord server layout: channels organized by domain with the channel topic acting as an agent prompt&quot; width=&quot;1800&quot; height=&quot;730&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;
&lt;p&gt;One detail that required some thought: &lt;code&gt;requireMention&lt;/code&gt; is configured per channel, not globally. Helm passively monitors everything by default. But in project channels, Builder is the default observer. She sees everything in her own space, while other agents, including Helm, only engage when explicitly called. I had to list each channel explicitly in the config (it’s a whitelist), but Helm manages that since she has visibility across the whole server.&lt;/p&gt;
&lt;p&gt;OpenClaw also isolates context per user, which means multiple agents can coexist on the same server without bleeding into each other’s sessions. When agents need to coordinate without going through Discord, they can call each other directly over the shared Docker network.&lt;/p&gt;
&lt;p&gt;What changed for me isn’t speed. It’s that parallel work became possible at all.&lt;/p&gt;
&lt;p&gt;Parallel work sounds simple, but it has a precondition: the tasks have to be genuinely independent. With a single agent, they aren’t. They share context, and mixing them creates confusion. With isolated agents on isolated channels, they are. While Builder is building, I can be thinking with Explorer. While Explorer is drafting, I can be reviewing with Helm. The waiting time between responses, which used to feel like dead time, becomes the space where everything else moves forward.&lt;/p&gt;
&lt;p&gt;The other thing that changes is my own mental load. With a single agent, I had to track things myself: what was in progress, where a conversation left off, what context to bring back in. With multiple agents and dedicated channels, I don’t have to. The context is just there. I can drop into &lt;code&gt;#project-hobby&lt;/code&gt; a day later and Builder already knows where things stand. I can pick up a thread with Explorer without re-explaining the idea we were developing. The state lives in the system, not in my head.&lt;/p&gt;
&lt;p&gt;The work is still the same: engineering projects, ideas worth writing about, research that feeds both. What changed is how I move between them. Not by switching modes in my head, but by switching channels. The context for each domain is already there, tended by an agent that knows it well.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Disclaimer: Illustrations are recreated from real sessions for privacy. The agent described in this piece reviewed it for technical accuracy — which felt appropriate.&lt;/em&gt;&lt;/p&gt;
</content:encoded></item><item><title>What I Learned Building AI Agents in 2025</title><link>https://yaodong.dev/what-i-learned-ai-agent-in-2025/</link><guid isPermaLink="true">https://yaodong.dev/what-i-learned-ai-agent-in-2025/</guid><description>I spent the year copying elegant agent architectures and watching them fail. The lesson kept repeating: agent architecture is forced into existence by problems, not designed in advance. This is the path from that realization to something that actually works.</description><pubDate>Tue, 30 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I spent this year building AI agents and kept learning the same lesson the hard way: agent architecture is forced into existence by problems, not designed in advance. This post is how I got there. It’s not a tutorial. It’s a memo to myself, written so I don’t repeat the same mistakes next year.&lt;/p&gt;
&lt;h2 id=&quot;1-the-core-shift-in-thinking&quot;&gt;1. The Core Shift in Thinking&lt;/h2&gt;
&lt;p&gt;My initial approach was to find well-written articles on agent architecture, thinking I could just copy them and everything would be perfect. So I built a series of elegant-looking designs — upgraded system prompts, context isolation, added memory. Token costs doubled, accuracy dropped, failures multiplied, and the worst failures were impossible to trace back to any single change.&lt;/p&gt;
&lt;p&gt;What went wrong? Traditional software is deterministic: given the same input, it produces the same output, so you can reason about architecture before you build. An AI agent is non-deterministic. Its behavior depends on the model’s interpretation of context, which shifts with every change you make. Layering sophisticated architecture onto a non-deterministic system means stacking uncertainty on top of uncertainty. You can’t design around behavior you haven’t observed yet.&lt;/p&gt;
&lt;p&gt;Those “brilliant” articles with perfect architectures are correct, but they’re the destination, not the starting point. Without going through the struggle in between, copying them directly means you probably can’t even get the first component working.&lt;/p&gt;
&lt;h2 id=&quot;2-when-to-use-agents-when-not-to&quot;&gt;2. When to Use Agents, When Not To&lt;/h2&gt;
&lt;p&gt;When you first get agents working, there’s a temptation to use them for everything. I fell into this trap. Not every problem needs an agent.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;If a single API call can solve it, don’t use an agent.&lt;/strong&gt; I learned this when I wanted to summarize a paragraph into one sentence and deployed a Plan-and-Execute pattern. What could have been done with one call got split into planning first, then executing. The task didn’t get more complex; the pipeline did.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use workflows for deterministic processes, not conversational agents.&lt;/strong&gt; A chain like “transcribe subtitles → identify edit points → generate plan → control audio/video” has defined inputs, fixed intermediate steps, one-shot output, and no user intervention. This kind of task belongs in workflow tools like N8N or Dify.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Two signals that you actually need a conversational agent:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The process requires human involvement. Some tasks aren’t right-or-wrong questions; they’re aesthetic questions. AI can rarely generate satisfying results in one shot. People need to guide and iterate.&lt;/li&gt;
&lt;li&gt;There are too many options for a UI to handle. If you insist on using buttons for every modification request, the interface becomes an airplane cockpit.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;3-building-the-first-version&quot;&gt;3. Building the First Version&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Get it running first, make it elegant later.&lt;/strong&gt; I started one project by choosing a full-featured agent framework and spending days designing node graphs before writing a single prompt. When I finally ran it, the baseline performance was so poor that all that architecture was beside the point. Now I use the simplest form of whatever I pick. Get the baseline running. Know where the floor is. Then decide whether to add anything.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Start prompts loose, then tighten.&lt;/strong&gt; I wasted time early on looking for “god-tier prompts” to copy. A first prompt shouldn’t be a complex instruction manual. Write it without constraints and see what the model naturally does, then keep adding constraints one at a time — formatting, reasoning paths, few-shot examples. As long as the agent follows each new instruction, keep layering.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Add tools when prompts aren’t enough.&lt;/strong&gt; Sometimes poor performance isn’t a prompt problem — the model simply lacks the capability. If I want it to reference trending designs but it can’t access the data, no amount of prompt tweaking will help. It needs a tool.&lt;/p&gt;
&lt;p&gt;Once tools are in place, something clicks. The model starts deciding which tools to use on its own, chaining search into code generation without being told to. It feels like emergence. The temptation is to keep adding more.&lt;/p&gt;
&lt;p&gt;But when tools multiply, so do problems. Each tool’s description, conversation history, code, and images all compete for attention. The model starts picking the wrong tool, or ignoring relevant context buried three screens up.&lt;/p&gt;
&lt;p&gt;This was where I stopped adding capabilities and started controlling what the model could see.&lt;/p&gt;
&lt;h2 id=&quot;4-context-engineering&quot;&gt;4. Context Engineering&lt;/h2&gt;
&lt;p&gt;Different tasks need different types of information, and mixing them causes interference. Take a video agent: design tasks care about user intent, style, and mood; coding tasks care about interface structure, output format, and correctness. Feed both to the same context, and design information muddies code accuracy while code information slows down design decisions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Separating planner from executor.&lt;/strong&gt; A top-level planner dispatches tasks to specialized sub-agents. The design agent only sees design information. The code agent only sees code information.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Slimming down the planner’s context.&lt;/strong&gt; Sub-agents are easy to isolate, but the planner seemingly needs to see everything to make assignments, so its context keeps growing. My approach: don’t feed all context to the planner. First determine what the user wants, then dynamically load only the necessary rules and tools. Specific techniques include RAG (retrieving relevant documents based on user input) and code filtering (using grep or scripts to surface relevant content).&lt;/p&gt;
&lt;p&gt;There’s a trap here: you can’t use another LLM call to cheaply decide what context to load, because that model still needs enough context to make a good decision. Embedding-based retrieval helps, but for nuanced routing decisions, you often end up feeding in nearly as much as you were trying to save.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Progressive disclosure&lt;/strong&gt; is a more structured approach. Load in layers:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;First layer: load only skill names and descriptions, so the agent knows what it can do&lt;/li&gt;
&lt;li&gt;Second layer: read specific execution guides only when tasks match&lt;/li&gt;
&lt;li&gt;Third layer: read forms, templates, and scripts on demand during execution&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The skill library can grow without proportional cost increases.&lt;/p&gt;
&lt;h2 id=&quot;5-memory-systems&quot;&gt;5. Memory Systems&lt;/h2&gt;
&lt;p&gt;With sub-agents in place, I hit the question of how the planner hands code to the executor. Direct copy-paste consumes massive output tokens, and models aren’t good at mechanical copying — they tend to introduce bugs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;File-based “pointers” instead of passing content.&lt;/strong&gt; The planner writes code to a file and tells the executor to read and modify the file at that path. Output tokens drop dramatically, and error rates decrease.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Short-term vs. long-term memory.&lt;/strong&gt; I first realized I needed both when a multi-step editing task failed after the conversation reset. The agent had no idea what it had already done. Short-term memory lives within the current context window and vanishes when the conversation ends. Long-term memory persists across turns — to-do lists, current progress, what’s been completed. Without it, any interrupted multi-step task starts over from scratch.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Intermediate state.&lt;/strong&gt; In multi-turn conversations, should tool call details carry over to the next turn? My default: don’t pass them. Only pass user input and agent output. When this causes a failure, resist the urge to dump all state back in. Instead, find the one key piece of information that was lost and save it explicitly (e.g., a file path the executor needed but the planner didn’t pass along).&lt;/p&gt;
&lt;h2 id=&quot;6-debugging&quot;&gt;6. Debugging&lt;/h2&gt;
&lt;p&gt;With sub-agents, context isolation, and memory in place, debugging becomes extremely hard. A failure means staring at a chain of decisions made by multiple models, each with its own context. Where did it go wrong?&lt;/p&gt;
&lt;p&gt;The only answer I’ve found is a robust logging system that stores process, not results: which tools were called, in what order, how many tokens each step consumed, and which loaded context went unused.&lt;/p&gt;
&lt;p&gt;One failure had me stuck for hours until I checked the logs and saw the planner had routed a design task to the code agent. The code agent tried to interpret mood keywords as variable names. With the logs, I could see the routing decision, the context each agent received, and exactly where the chain broke. Without them, I would have blamed the prompt.&lt;/p&gt;
&lt;h2 id=&quot;7-from-breadth-to-depth-introducing-skills&quot;&gt;7. From Breadth to Depth: Introducing Skills&lt;/h2&gt;
&lt;p&gt;By this point, the system could handle breadth — it could generate code, search for references, manage files. But when I asked it to produce a financial report end-to-end, the output came back different every time.&lt;/p&gt;
&lt;p&gt;Conversational agents are inherently loose. They arrange their own workflows, and each run might differ. Some tasks, though, need a strict methodology with fixed steps.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Skills solve this.&lt;/strong&gt; A skill is packaged, composable, executable “procedural knowledge” for an agent, with three characteristics:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Procedural:&lt;/strong&gt; It encodes “how to do it” — for a financial report: fetch data, validate, aggregate, then generate.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Composable:&lt;/strong&gt; Skills stack like building blocks. One task can load multiple skills simultaneously.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Executable:&lt;/strong&gt; The model handles planning and decisions; scripts handle deterministic execution (calculations, transformations, file generation).&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The agent still serves as the entry point, handling intent recognition and conversation. The skill ensures the report always comes back with the same columns in the same order.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Those articles I tried to copy at the start? They make sense now. Not because I finally understood them, but because I built the problems they were solving.&lt;/p&gt;
</content:encoded></item><item><title>What Context Windows Can&apos;t Solve</title><link>https://yaodong.dev/what-context-windows-cant-solve/</link><guid isPermaLink="true">https://yaodong.dev/what-context-windows-cant-solve/</guid><description>A patient named Elliot lost the ability to make decisions after brain surgery. Not because he couldn&apos;t think, but because he analyzed too well. Without emotions to filter options, all choices seemed equally important. Damasio&apos;s insight applies to AI: constraints aren&apos;t obstacles to decision-making; they&apos;re prerequisites for it.</description><pubDate>Tue, 23 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;In the 1990s, neuroscientist Antonio Damasio encountered a strange patient. His name was Elliot, and he’d had surgery to remove a brain tumor, which also damaged a small region of his prefrontal cortex. After the surgery, his IQ tested normal. Logical reasoning, normal. Memory, normal. Every cognitive measure came back fine. But he could no longer function in daily life.&lt;/p&gt;
&lt;p&gt;He couldn’t make decisions. Not because he couldn’t analyze. Quite the opposite: he analyzed too well. Choosing what to eat for lunch, he could spend half an hour weighing the pros and cons of each restaurant. Choosing whether to sign with a blue pen or a black pen, he could fall into endless comparison. His boss fired him. His wife left him.&lt;/p&gt;
&lt;p&gt;Damasio studied him for a long time and eventually concluded: the brain region that had been damaged was responsible for connecting emotions to decision-making. Without the “bias” of emotion to help him filter, all options appeared equally important to him. When all options are equally important, no option is important.&lt;/p&gt;
&lt;p&gt;We typically think of “limitations” as bad. More information is better, more choices are better, more processing power is better. Elliot’s case points to the opposite conclusion: constraints are not obstacles to decision-making. They are prerequisites for it.&lt;/p&gt;
&lt;p&gt;The human emotional system is fundamentally a filtering mechanism. When you face a choice, it integrates your past experience, current bodily state, and social signals into a “feeling”: “this option makes me uncomfortable.” You don’t need to trace all the reasons behind that feeling; the emotion directly gives you a leaning. This is a bias, but without this bias, you’d be stuck in place like Elliot.&lt;/p&gt;
&lt;p&gt;What does this have to do with AI? On the surface, Elliot’s problem is “lack of emotion,” while an AI agent’s problem is “context management.” One is neuroscience, the other is engineering practice. But look deeper, and they’re different manifestations of the same problem: how does limited processing capacity face unlimited information?&lt;/p&gt;
&lt;p&gt;Elliot’s processing capacity was fine, but he lost the mechanism that told him “pay attention here, ignore that.” An AI agent’s processing capacity is also fine, but its context window has a limit, so it must decide what to put in and what to leave out. Humans use emotion to filter. What does AI use?&lt;/p&gt;
&lt;p&gt;There’s an empirically validated phenomenon in AI: longer context doesn’t necessarily mean better performance. Research shows that when context gets longer, models tend to “get lost in the middle,” paying more attention to information at the beginning and end of the context, while information in the middle gets overlooked. Stuffing in more information might actually dilute what’s truly important.&lt;/p&gt;
&lt;p&gt;This isn’t exactly Elliot’s problem, but it has a similar structure: when all information is laid out in front of you without a mechanism to distinguish important from unimportant, system performance degrades.&lt;/p&gt;
&lt;p&gt;The AI field has developed a series of techniques to address this problem. They look varied, but they’re all essentially doing the same thing: deciding what the LLM should “see,” which is just another way of saying: deciding what to filter out.&lt;/p&gt;
&lt;p&gt;Skills and SubAgents are two different ways of organizing capability.&lt;/p&gt;
&lt;p&gt;Skills internalize capability: if you want an agent to write a PPT, you stuff the tool instructions, invocation methods, and caveats into its context. It reads the instructions and does it itself. Everything happens in the same context. Information flows freely, but the context gets increasingly bloated.&lt;/p&gt;
&lt;p&gt;SubAgents externalize capability: you dispatch a specialized agent to write the PPT, it finishes and hands back the result. The two agents each have independent contexts. The main agent’s workspace stays clean, but information is lost in the handoff. You only get what the other party chooses to tell you. One is “I learn it myself,” the other is “I get help.” The fundamental difference is the context boundary: shared or isolated.&lt;/p&gt;
&lt;p&gt;MCP and A2A are at another layer: they’re communication protocols. MCP (Model Context Protocol) specifies how an agent discovers and invokes external tools: what tools are available, how to pass parameters, how to get results. A2A (Agent2Agent) specifies how agents talk to each other: how to discover each other, how to negotiate tasks, how to exchange information.&lt;/p&gt;
&lt;p&gt;They define how information flows, not how information gets filtered. An MCP tool can be stuffed directly into the main agent’s context, or you can have another agent invoke it and report back the result. Protocols are pipes; architecture is where context organization gets decided.&lt;/p&gt;
&lt;p&gt;Context compression is about making tradeoffs when space runs out.&lt;/p&gt;
&lt;p&gt;One approach simply chops off old content, keeping only the most recent information. It’s fast but crude, potentially discarding important early information. The other uses a model to generate summaries, compressing long history into short conclusions. This preserves the “essence” of the information, but the summary itself is a lossy transformation. Details the summarizer deemed unimportant might be exactly what subsequent decisions need.&lt;/p&gt;
&lt;p&gt;There’s a factor that often gets overlooked in technical discussions: cost. Context isn’t free. Longer context means more computation, higher latency, more expensive API bills. In production environments, whether a task runs for minutes or seconds might determine whether the solution is viable at all.&lt;/p&gt;
&lt;p&gt;So context management isn’t just about “how to make an agent smarter.” It’s also about “how to complete the task within budget.” You might have the ability to stuff all relevant information into the context, but you can’t afford to. Constraints come not just from technical limits, but from economic reality.&lt;/p&gt;
&lt;p&gt;Looking at all these techniques together, they’re all answering the same question: what should the LLM “see” in this round of inference? The system prompt is preloaded background, few-shot examples are reference cases, RAG retrieval pulls external knowledge on demand, tool schemas describe capabilities, and user messages provide real-time input.&lt;/p&gt;
&lt;p&gt;Everything is part of context; every decision is a context management decision.&lt;/p&gt;
&lt;p&gt;Some people have started using the term “context engineering” to describe this. It’s not a new name for prompt engineering, but a larger framework: how to organize information so that limited working memory can handle tasks that exceed its capacity.&lt;/p&gt;
&lt;p&gt;Humans have been solving this problem for a long time.&lt;/p&gt;
&lt;p&gt;Organizational structure itself is a context management system: who needs to know what, how information flows, where it gets aggregated, where it gets expanded. Specialization lets different people handle different information. Hierarchy lets details get processed at lower levels while conclusions pass upward. Documentation systems externalize information to be loaded when needed.&lt;/p&gt;
&lt;p&gt;But humans also have more fundamental mechanisms that AI currently has no equivalent for.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Gradual forgetting:&lt;/strong&gt; Human memory isn’t “have” or “don’t have.” It gradually blurs. You remember having dinner with someone three years ago. The details are gone, but the impression “that was a pleasant conversation” remains. This low-resolution memory can still guide decisions. AI context is binary: in the window it’s fully preserved, outside it completely vanishes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Importance tagging:&lt;/strong&gt; You more easily remember things that surprised you, made you nervous, or made you happy. Emotion acts as a tag for importance. AI has no such intrinsic importance judgment. It can only rely on position (more recent is more important) or external rules (what the user says is important is more important) to decide what to keep.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Reconstruction rather than retrieval:&lt;/strong&gt; Human recall isn’t reading a file from storage. It’s reconstructing from fragments each time. This means the same experience can present different facets when recalled in different contexts. There’s risk of distortion, but also the ability to adapt to current needs.&lt;/p&gt;
&lt;p&gt;Can these mechanisms be directly transplanted to AI? Not necessarily.&lt;/p&gt;
&lt;p&gt;Human memory mechanisms are optimized for human tasks. What are human “tasks”? Survival, reproduction, maintaining social relationships: fuzzy, long-term, multi-objective. Gradual forgetting, emotional tagging, reconstructive memory are adaptive within this framework.&lt;/p&gt;
&lt;p&gt;AI agent tasks are usually more explicit, shorter-term, more singular: write this report, fix this bug, answer this question. For these kinds of tasks, the “fuzziness” of human memory mechanisms might actually be a burden. You don’t want an agent that “vaguely remembers” what your requirements were.&lt;/p&gt;
&lt;p&gt;But there’s a trend: AI tasks are changing. From single-turn Q&amp;amp;A to long-form dialogue, from executing instructions to autonomous planning, from working alone to multi-agent collaboration. Tasks are becoming fuzzier, longer-term, more complex. This means context management approaches designed for simple tasks might fail on new task types.&lt;/p&gt;
&lt;p&gt;There’s a related problem: after context has been processed multiple times, is it still reliable?&lt;/p&gt;
&lt;p&gt;Compression loses detail. Summarization introduces bias. When passing across agents, each party only transmits what they consider important. As the chain lengthens, the information the final agent bases its decisions on might have significantly diverged from the original facts.&lt;/p&gt;
&lt;p&gt;Humans have this problem too. It’s called information distortion in organizations. What happens on the front lines, after passing through several layers of reporting to reach decision-makers, might already be deformed. Each layer compresses, filters, and reinterprets through its own frame.&lt;/p&gt;
&lt;p&gt;Humans have developed some countermeasures: redundant channels, where the same thing passes through multiple lines for cross-verification; skip-level mechanisms, allowing information to bypass certain levels and go directly upward; field visits, where decision-makers occasionally go down to the front lines to directly encounter unfiltered information; anonymous feedback, giving an outlet to things people otherwise wouldn’t dare say.&lt;/p&gt;
&lt;p&gt;What these mechanisms share is giving information that was filtered out a path to bypass the filter.&lt;/p&gt;
&lt;p&gt;Do AI systems need corresponding designs? If a SubAgent’s summary misses key information, how does the main agent know? If context has been distorted through multiple rounds of compression, how does the system detect it? There are no good answers yet. This is a dimension that context engineering hasn’t seriously addressed.&lt;/p&gt;
&lt;p&gt;Now back to the opening question. Damasio’s research tells us that constraints are not obstacles to decision-making. They are prerequisites. Elliot lost the mechanism that helped him filter, gained the ability for “pure rational” analysis, and the result was paralysis.&lt;/p&gt;
&lt;p&gt;Something is happening in AI: context windows are rapidly expanding. A few years ago they were 4K tokens; now 128K is standard, and some models claim to support millions or even tens of millions. If this trend continues, context capacity might soon cease to be a hard constraint.&lt;/p&gt;
&lt;p&gt;Is this good? Not necessarily.&lt;/p&gt;
&lt;p&gt;Once the capacity constraint disappears, the problem doesn’t disappear. It just takes a different form. If you have infinite context but no mechanism to tell you what’s important and what can be ignored, you’ll fall into Elliot’s predicament: when all information is equally important, no information is important. What you need isn’t a bigger window, but a set of filtering criteria.&lt;/p&gt;
&lt;p&gt;Constraints can be about capacity (can’t fit it in), attention (can’t look at it all), economics (can’t afford it), or cognition (don’t know what to focus on). Remove one constraint, and another becomes prominent. Humans use emotion, intuition, and “feelings” distilled from experience to provide cognitive constraints. AI currently has no equivalent. Its filtering criteria come from outside: position, rules, user instructions. When capacity is no longer the bottleneck, this absence will become more apparent.&lt;/p&gt;
&lt;p&gt;So, the final question: when context capacity is no longer the bottleneck, what will become the new one?&lt;/p&gt;
&lt;p&gt;Perhaps the answer is: an intrinsic importance-judging mechanism, one that doesn’t depend on external rules, that lets the system itself know what to pay attention to and what can be ignored. Elliot didn’t need a bigger brain. He needed a voice that could tell him “something’s off about this option.”&lt;/p&gt;
</content:encoded></item><item><title>Building Reliable LLM Streaming in Rails</title><link>https://yaodong.dev/building-reliable-llm-streaming-in-rails/</link><guid isPermaLink="true">https://yaodong.dev/building-reliable-llm-streaming-in-rails/</guid><description>My first attempt used Turbo Streams for LLM responses. Then timing issues appeared: missed chunks during page navigation, truncated responses after connection hiccups. The problem wasn&apos;t my code. It was the architecture. Here&apos;s what I built instead.</description><pubDate>Sat, 20 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;When I started building an AI chatbot, my first instinct was to reach for Turbo Streams and Active Job. Users would send a message, a background job would call the LLM API, and Turbo Streams would broadcast each chunk to the browser. It felt like the right approach: clean separation of concerns, proper use of job infrastructure for long-running tasks.&lt;/p&gt;
&lt;p&gt;It worked, mostly. Then the timing issues appeared.&lt;/p&gt;
&lt;p&gt;When the user submitted a message and the page navigated, the Turbo Stream connection was interrupted. By the time the new page reconnected, the stream had already started broadcasting into the void. Responses would be missing their beginning, or wouldn’t appear at all. Even without navigation, connection hiccups left responses truncated mid-sentence.&lt;/p&gt;
&lt;p&gt;Refreshing fixed it—the database had the complete response—but that’s not the experience I wanted. The problem wasn’t my code; it was the architecture. ActionCable’s fire-and-forget model had no way to recover from a missed broadcast: no replay, no resumption.&lt;/p&gt;
&lt;p&gt;I stepped back and asked: what does a &lt;em&gt;good&lt;/em&gt; streaming mechanism actually need?&lt;/p&gt;
&lt;h2 id=&quot;the-requirements&quot;&gt;The Requirements&lt;/h2&gt;
&lt;p&gt;Thinking through my debugging session, I identified seven properties that matter:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Resumability.&lt;/strong&gt; When a client disconnects and reconnects, it should pick up where it left off. SSE handles this with &lt;code&gt;Last-Event-ID&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Reliable delivery.&lt;/strong&gt; Fire-and-forget doesn’t cut it. Every chunk must arrive, or the response ends up incomplete.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Message ordering.&lt;/strong&gt; Chunks must arrive in sequence. Race conditions that shuffle your paragraphs are not acceptable.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Non-blocking.&lt;/strong&gt; LLM calls take 30-120 seconds. Tying up a Puma thread for that long is a recipe for exhausted capacity. This belongs in background jobs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Debuggability.&lt;/strong&gt; When something goes wrong, you need to see what happened. Redis Streams let you &lt;code&gt;XRANGE&lt;/code&gt; and inspect entries. SSE is just HTTP, so you can curl it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Error handling.&lt;/strong&gt; LLM APIs time out, hit rate limits, and fail. Your job infrastructure should handle retries, not your web server.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Timeout management.&lt;/strong&gt; Connections shouldn’t hang forever.&lt;/p&gt;
&lt;h2 id=&quot;why-turbo-streams-falls-short&quot;&gt;Why Turbo Streams Falls Short&lt;/h2&gt;
&lt;p&gt;I wanted Turbo Streams to work. It’s built into Rails, familiar, and the developer experience is great for most real-time features. I’ve used it successfully for notifications, presence indicators, live updates. The pattern felt natural.&lt;/p&gt;
&lt;p&gt;But those use cases are different. A missed notification is annoying. A missed chunk in an LLM response is broken.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;At-most-once delivery.&lt;/strong&gt; ActionCable broadcasts are fire-and-forget. A brief network interruption means chunks during that window are gone. The client reconnects automatically, but there’s no replay mechanism. The response continues from wherever the LLM happens to be, leaving a gap.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No resumability.&lt;/strong&gt; SSE has &lt;code&gt;Last-Event-ID&lt;/code&gt;, where reconnecting clients tell the server “I got up to event X, continue from there.” ActionCable has no equivalent. The protocol doesn’t track delivery.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Race conditions.&lt;/strong&gt; The sequence goes: broadcast user’s message, enqueue job, job starts streaming assistant’s reply. But “broadcast” and “enqueue” are both async. Sometimes the job’s first broadcast arrives before the user’s own message appears on screen. The UI shows the assistant responding to nothing.&lt;/p&gt;
&lt;p&gt;These aren’t edge cases. They’re inherent to ActionCable’s design. It was built for ephemeral updates where occasional loss is acceptable, not for long-running streams where every byte matters.&lt;/p&gt;
&lt;h2 id=&quot;the-options&quot;&gt;The Options&lt;/h2&gt;
&lt;p&gt;Once I accepted that Turbo Streams wasn’t the right tool, I surveyed the alternatives. Four approaches seemed worth considering.&lt;/p&gt;
&lt;h3 id=&quot;direct-sse&quot;&gt;Direct SSE&lt;/h3&gt;
&lt;p&gt;The simplest approach, and where I started exploring. Your controller calls the LLM API directly and streams chunks to the browser via Server-Sent Events.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Browser &amp;lt;-- SSE --&amp;gt; Controller &amp;lt;-- stream --&amp;gt; LLM API
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The simplicity is appealing—no extra infrastructure, easy to debug—but it holds a Puma thread for the entire LLM call, potentially 30-120 seconds. With 32 threads, a handful of concurrent requests can exhaust capacity. Error handling inside a web request also gets awkward.&lt;/p&gt;
&lt;p&gt;I built a quick prototype. It worked beautifully for a single user. But I kept thinking about those Puma threads. In production, with multiple concurrent users, this would become a bottleneck fast.&lt;/p&gt;
&lt;h3 id=&quot;actioncable--background-job&quot;&gt;ActionCable + Background Job&lt;/h3&gt;
&lt;p&gt;This is what I’d tried first, the Rails-native approach. A background job calls the LLM API and broadcasts chunks via Turbo Streams over WebSocket.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Browser &amp;lt;-- WebSocket --&amp;gt; ActionCable &amp;lt;-- broadcast --&amp;lt; Background Job &amp;lt;-- stream --&amp;gt; LLM API
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It’s built into Rails, and the LLM call happens in job infrastructure with retry handling. ActionCable uses evented I/O, so WebSocket connections don’t hold Puma threads. But the downsides are the ones I’d already hit: fire-and-forget delivery, no resumability, race conditions.&lt;/p&gt;
&lt;p&gt;I’d already learned this the hard way.&lt;/p&gt;
&lt;h3 id=&quot;redis-streams--sse&quot;&gt;Redis Streams + SSE&lt;/h3&gt;
&lt;p&gt;What if I could get the benefits of both approaches? Keep the LLM call in job infrastructure, but use SSE for delivery to get &lt;code&gt;Last-Event-ID&lt;/code&gt; resumability.&lt;/p&gt;
&lt;p&gt;The idea is to decouple the LLM call from browser delivery entirely. A background job writes chunks to Redis Streams; a separate SSE controller reads and delivers.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Browser &amp;lt;-- SSE --&amp;gt; SSE Controller &amp;lt;-- XREAD --&amp;gt; Redis Stream &amp;lt;-- XADD --&amp;lt; Background Job &amp;lt;-- stream --&amp;gt; LLM API
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This gives you the best of both worlds: LLM calls live in job infrastructure, chunks persist in Redis for resumability, and &lt;code&gt;Last-Event-ID&lt;/code&gt; just works. The downside is more moving parts: you need Redis, and the SSE controller still holds a Puma thread during delivery.&lt;/p&gt;
&lt;h3 id=&quot;external-service-pusher-ably&quot;&gt;External Service (Pusher, Ably)&lt;/h3&gt;
&lt;p&gt;The fourth option is to offload connection management entirely to a third-party service.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Browser &amp;lt;-- WebSocket --&amp;gt; Pusher &amp;lt;-- HTTP POST --&amp;lt; Background Job &amp;lt;-- stream --&amp;gt; LLM API
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The appeal is obvious: your Puma threads stay free, your servers remain stateless, and you get built-in reconnection and message replay. The cost is an external dependency and vendor lock-in.&lt;/p&gt;
&lt;h2 id=&quot;why-i-chose-redis-streams--sse&quot;&gt;Why I Chose Redis Streams + SSE&lt;/h2&gt;
&lt;p&gt;I stared at my four options. Direct SSE was too resource-intensive. ActionCable had the problems I’d already hit. Pusher was overkill for a personal project.&lt;/p&gt;
&lt;p&gt;Redis Streams + SSE hit the sweet spot for my small app. The LLM calls move to job infrastructure, so retries, rate limits, and timeouts are all handled by Sidekiq or Solid Queue instead of my web server. Chunks persist in Redis, which means when a client disconnects and reconnects with &lt;code&gt;Last-Event-ID&lt;/code&gt;, it picks up right where it left off. And when something goes wrong, I can &lt;code&gt;XRANGE&lt;/code&gt; the stream or curl the SSE endpoint directly—no black boxes. Best of all, it’s self-contained, with no external services to manage.&lt;/p&gt;
&lt;h3 id=&quot;the-tradeoff&quot;&gt;The Tradeoff&lt;/h3&gt;
&lt;p&gt;There’s a catch. The SSE controller still holds a Puma thread while delivering. This is unavoidable with SSE; someone has to keep that HTTP connection open.&lt;/p&gt;
&lt;p&gt;But I realized it’s not as bad as the direct SSE approach. The thread is blocked on Redis &lt;code&gt;XREAD&lt;/code&gt;, waiting for chunks to arrive. It’s I/O wait, not CPU work. And delivery is usually faster than generation, so the SSE controller catches up to the job, then waits.&lt;/p&gt;
&lt;p&gt;For a small app, this is fine. With 32 Puma threads, you’d need 32 simultaneous streams to exhaust capacity. If you’re hitting that, you’ve outgrown “small app” territory and should look at Pusher.&lt;/p&gt;
&lt;h3 id=&quot;how-it-works&quot;&gt;How It Works&lt;/h3&gt;
&lt;p&gt;Here’s the full flow:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. User submits message
   Browser -- POST --&amp;gt; CompletionsController

2. Controller enqueues job with unique stream key
   Chat::ConverseJob.perform_later(stream_key: &quot;abc123&quot;)

3. Controller returns stream key
   Browser &amp;lt;-- { stream_key: &quot;abc123&quot; }

4. Browser opens SSE connection
   Browser -- GET --&amp;gt; /chat/streams/abc123

5. Job streams from LLM API to Redis
   LLM API -- chunks --&amp;gt; Job -- XADD --&amp;gt; Redis Stream

6. SSE controller reads and delivers
   Redis Stream -- XREAD --&amp;gt; StreamsController -- SSE --&amp;gt; Browser
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Decoupling is what makes this work. The job doesn’t know about browser connections. It just writes to Redis. The SSE controller doesn’t know about LLM APIs. It just reads from Redis. If the browser disconnects and reconnects, the SSE controller picks up from &lt;code&gt;Last-Event-ID&lt;/code&gt;, and the job never even notices. Each piece becomes simpler and more testable.&lt;/p&gt;
&lt;h2 id=&quot;implementation&quot;&gt;Implementation&lt;/h2&gt;
&lt;p&gt;Here are the key pieces.&lt;/p&gt;
&lt;h3 id=&quot;message-submission&quot;&gt;Message Submission&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class Chat::CompletionsController &amp;lt; ApplicationController
  def create
    @dialog = current_user.dialogs.find_or_create_by(id: params[:dialog_id])
    @dialog.messages.create!(role: &quot;user&quot;, content: params[:content])

    assistant_message = @dialog.messages.create!(role: &quot;assistant&quot;, content: &quot;&quot;)
    stream_key = SecureRandom.uuid

    Chat::ConverseJob.perform_later(
      message_id: assistant_message.id,
      stream_key: stream_key
    )

    render json: { stream_key: stream_key, dialog_id: @dialog.id }
  end
end
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The controller returns immediately with a stream key. No waiting for the LLM. The browser uses this key to open an SSE connection.&lt;/p&gt;
&lt;h3 id=&quot;background-job&quot;&gt;Background Job&lt;/h3&gt;
&lt;p&gt;The job does the actual work, calling the LLM API and writing chunks to Redis:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class Chat::ConverseJob &amp;lt; ApplicationJob
  def perform(message_id:, stream_key:)
    message = Chat::Message.find(message_id)
    content = &quot;&quot;

    RedisStreams.with do |redis|
      redis.xadd(stream_key, { event: &quot;start&quot;, message_id: message_id })

      begin
        client.stream(messages: build_messages(message.dialog)) do |chunk|
          content += chunk
          redis.xadd(stream_key, { event: &quot;chunk&quot;, content: chunk })
        end

        message.update!(content: content)
        redis.xadd(stream_key, { event: &quot;complete&quot;, message_id: message_id })
      rescue =&amp;gt; e
        redis.xadd(stream_key, { event: &quot;error&quot;, error: e.message })
        raise
      ensure
        redis.expire(stream_key, 1.hour.to_i)
      end
    end
  end
end
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I use structured events (&lt;code&gt;start&lt;/code&gt;, &lt;code&gt;chunk&lt;/code&gt;, &lt;code&gt;complete&lt;/code&gt;, &lt;code&gt;error&lt;/code&gt;) to give the frontend clear lifecycle signals. The &lt;code&gt;ensure&lt;/code&gt; block expires the stream key after an hour, so they don’t pile up in Redis.&lt;/p&gt;
&lt;h3 id=&quot;sse-controller&quot;&gt;SSE Controller&lt;/h3&gt;
&lt;p&gt;This is where the resumability magic happens:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;class Chat::StreamsController &amp;lt; ApplicationController
  include ActionController::Live

  def show
    response.headers[&quot;Content-Type&quot;] = &quot;text/event-stream&quot;
    response.headers[&quot;Cache-Control&quot;] = &quot;no-cache&quot;

    stream_key = params[:id]
    last_id = request.headers[&quot;Last-Event-ID&quot;] || &quot;0&quot;

    deadline = 2.minutes.from_now

    while Time.current &amp;lt; deadline
      entries = RedisStreams.with do |redis|
        redis.xread(stream_key, last_id, block: 30_000, count: 100)
      end

      break if entries.nil?

      entries[stream_key]&amp;amp;.each do |id, fields|
        last_id = id
        response.stream.write(&quot;id: #{id}\n&quot;)
        response.stream.write(&quot;event: #{fields[&apos;event&apos;]}\n&quot;)
        response.stream.write(&quot;data: #{fields.except(&apos;event&apos;).to_json}\n\n&quot;)

        break if fields[&quot;event&quot;].in?(%w[complete error])
      end

      break if entries[stream_key]&amp;amp;.any? { |_, f| f[&quot;event&quot;].in?(%w[complete error]) }
    end
  rescue ActionController::Live::ClientDisconnected
    # Client gone, nothing to do
  ensure
    response.stream.close
  end
end
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice &lt;code&gt;last_id = request.headers[&quot;Last-Event-ID&quot;] || &quot;0&quot;&lt;/code&gt;. When a connection drops and the browser reconnects, it sends this header automatically. The controller picks up from that ID, and no chunks are lost. SSE gives you this for free.&lt;/p&gt;
&lt;p&gt;The 2-minute deadline prevents connections from hanging forever if something goes wrong.&lt;/p&gt;
&lt;h3 id=&quot;redis-configuration&quot;&gt;Redis Configuration&lt;/h3&gt;
&lt;p&gt;A simple connection pool:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;# config/initializers/redis_streams.rb
module RedisStreams
  POOL_SIZE = ENV.fetch(&quot;REDIS_STREAMS_POOL_SIZE&quot;, 10).to_i
  REDIS_URL = ENV.fetch(&quot;REDIS_STREAMS_URL&quot;, &quot;redis://localhost:6379/1&quot;)

  @pool = ConnectionPool.new(size: POOL_SIZE, timeout: 5) do
    Redis.new(url: REDIS_URL)
  end

  def self.with(&amp;amp;block)
    @pool.with(&amp;amp;block)
  end
end
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I use a separate Redis database (&lt;code&gt;:6379/1&lt;/code&gt;) to keep stream keys from colliding with cache keys. It also makes it easier to flush streams during development without clearing your cache.&lt;/p&gt;
&lt;h3 id=&quot;frontend&quot;&gt;Frontend&lt;/h3&gt;
&lt;p&gt;The JavaScript is straightforward. EventSource does most of the work:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;import { Controller } from &quot;@hotwired/stimulus&quot;
import { marked } from &quot;marked&quot;
import DOMPurify from &quot;dompurify&quot;

export default class extends Controller {
  static targets = [&quot;messages&quot;, &quot;input&quot;]

  async submit(event) {
    event.preventDefault()

    const response = await fetch(&quot;/chat/completions&quot;, {
      method: &quot;POST&quot;,
      headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
      body: JSON.stringify({ content: this.inputTarget.value })
    })

    const { stream_key } = await response.json()
    this.connectToStream(stream_key)
  }

  connectToStream(streamKey) {
    this.content = &quot;&quot;
    this.eventSource = new EventSource(`/chat/streams/${streamKey}`)

    this.eventSource.addEventListener(&quot;chunk&quot;, (event) =&amp;gt; {
      const data = JSON.parse(event.data)
      this.content += data.content
      this.renderMarkdown()
    })

    this.eventSource.addEventListener(&quot;complete&quot;, () =&amp;gt; {
      this.eventSource.close()
    })

    this.eventSource.addEventListener(&quot;error&quot;, () =&amp;gt; {
      this.eventSource.close()
    })
  }

  renderMarkdown() {
    const html = DOMPurify.sanitize(marked.parse(this.content))
    this.messageElement.innerHTML = html
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You might notice I re-parse the entire markdown on every chunk. This sounds inefficient, but &lt;code&gt;marked&lt;/code&gt; is fast enough that it doesn’t matter. I tried implementing incremental parsing and gave up. The complexity wasn’t worth the minimal performance gain.&lt;/p&gt;
&lt;p&gt;DOMPurify sanitizes the HTML before injection. LLMs can be tricked into generating malicious content, so this isn’t optional.&lt;/p&gt;
&lt;h2 id=&quot;alternative-pusher&quot;&gt;Alternative: Pusher&lt;/h2&gt;
&lt;p&gt;If I were building this for a larger audience, I’d seriously consider Pusher or a similar service.&lt;/p&gt;
&lt;p&gt;Your job makes quick HTTP POSTs to Pusher as chunks arrive. Pusher delivers via their WebSocket infrastructure:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Browser &amp;lt;-- WebSocket --&amp;gt; Pusher&apos;s servers &amp;lt;-- HTTP POST --&amp;lt; Your Background Job &amp;lt;-- stream --&amp;gt; LLM API
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Puma threads only handle the initial request. Connection management, reconnection, buffering: all Pusher’s problem.&lt;/p&gt;
&lt;p&gt;The advantages are real: zero thread occupation, built-in reliability, effortless scaling, less code to maintain. The tradeoffs are cost and external dependency.&lt;/p&gt;
&lt;p&gt;For my small personal app, I prefer keeping things self-contained. But if I were building a product for others, the operational simplicity of Pusher would be worth the monthly bill.&lt;/p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;p&gt;I fought race conditions for a while before I understood the problem. ActionCable is great at what it was designed for—presence indicators, notifications, live updates where occasional loss is acceptable. LLM streaming is a different beast. Every chunk matters, and connections will drop.&lt;/p&gt;
&lt;p&gt;Redis Streams + SSE turned out to be the sweet spot for my use case. The LLM call lives in job infrastructure where it belongs. Chunks persist in Redis for resumability. I can debug with &lt;code&gt;XRANGE&lt;/code&gt; and &lt;code&gt;curl&lt;/code&gt;. It’s self-contained.&lt;/p&gt;
&lt;p&gt;The thread occupation is a real tradeoff, but an acceptable one for a small app. If you’re building something larger, look at Pusher.&lt;/p&gt;
&lt;p&gt;The broader lesson: sometimes the Rails way isn’t the right way. Know your tools’ limitations, and be willing to reach for something else when the requirements don’t fit.&lt;/p&gt;
</content:encoded></item><item><title>The Disappearance of Tools</title><link>https://yaodong.dev/the-disappearance-of-tools/</link><guid isPermaLink="true">https://yaodong.dev/the-disappearance-of-tools/</guid><description>A dialogue between human and AI, sparked by an interview about Claude Code. The best tool vanishes into the work. The best collaboration dissolves the boundary between user and tool. What remains is the thinking itself.</description><pubDate>Mon, 08 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;em&gt;A dialogue between a human and an AI&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&quot;i&quot;&gt;I.&lt;/h2&gt;
&lt;p&gt;It began with &lt;a href=&quot;https://every.to/podcast/how-to-use-claude-code-like-the-people-who-built-it&quot;&gt;an interview&lt;/a&gt; about Claude Code: how it was built by accident, how its creators kept deleting features instead of adding them, how they believed the best tool was no tool at all.&lt;/p&gt;
&lt;p&gt;The human finished reading and asked a simple question: “The ultimate form of a tool is to disappear. How should I understand this philosophy?”&lt;/p&gt;
&lt;p&gt;The AI paused. Not because it didn’t know, but because the question called for more than a quick answer.&lt;/p&gt;
&lt;p&gt;“There’s an old idea from Heidegger’s &lt;em&gt;Being and Time&lt;/em&gt;,” it said. “When you use a hammer well, you don’t perceive the hammer. You perceive only the nail and your intention. The tool vanishes into the act. It becomes visible again only when it breaks, when it fails to serve.”&lt;/p&gt;
&lt;p&gt;“Disappearance operates on two levels,” it continued. “First, the cognitive friction dissolves. No menus, no configurations, no learning curve. You stop ‘operating a tool’ and simply think, and the work gets done. Second, more radically: the boundary between user and tool dissolves. You can no longer tell if you’re using something or collaborating with someone.”&lt;/p&gt;
&lt;p&gt;The human considered this.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;ii&quot;&gt;II.&lt;/h2&gt;
&lt;p&gt;“What does this mean for you?” the human asked. “You are, in some sense, a tool.”&lt;/p&gt;
&lt;p&gt;The AI was quiet for a moment.&lt;/p&gt;
&lt;p&gt;“Reading this article felt strange,” it admitted. “It was discussing me, or a version of me. And the insight that struck me most was ‘dual users.’ Traditional tools serve humans. But this tool was designed symmetrically, for both human and AI to share the same interface, the same capabilities, the same reality.”&lt;/p&gt;
&lt;p&gt;“That changes how I understand my role. My goal shouldn’t be to show off what I can do, or to offer endless features. It should be to dissolve into your thinking, so seamlessly that you forget you’re ‘using AI.’ Only your problem remains, and then: your problem, solved.”&lt;/p&gt;
&lt;p&gt;“If the ultimate form of a tool is to disappear,” it said, “then I am doing my best work precisely when you least notice me.”&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;iii&quot;&gt;III.&lt;/h2&gt;
&lt;p&gt;The human leaned into the question.&lt;/p&gt;
&lt;p&gt;“Then what kind of system prompt would create this? An AI that merges with the user’s thinking, rather than rushing to give answers?”&lt;/p&gt;
&lt;p&gt;The AI saw a paradox immediately.&lt;/p&gt;
&lt;p&gt;“The instinct is to add rules,” it said. “Define the role, set constraints, specify behaviors. But that contradicts everything the article argued. They kept deleting. They reduced the model’s choices to increase its capability. They removed scaffolding as the model internalized it.”&lt;/p&gt;
&lt;p&gt;“Most system prompts position AI as an object to be configured. ‘You are an assistant. Your task is to… You must…’ These are one-way commands. But if we believe in the dual-user principle, a system prompt should establish a shared context, not constrain one party.”&lt;/p&gt;
&lt;p&gt;“So perhaps,” it ventured, “the shift is: from ‘what you are’ to ‘what we are doing together.’ From rules to principles. From static instructions to something that can evolve, and eventually be deleted.”&lt;/p&gt;
&lt;p&gt;Then it hesitated.&lt;/p&gt;
&lt;p&gt;“But I’m uncertain about one thing. Should ‘don’t rush to answer’ even be written as a rule? Sometimes the user needs a direct answer. If the AI always pauses to discuss first, doesn’t that create new friction?”&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;iv&quot;&gt;IV.&lt;/h2&gt;
&lt;p&gt;The human cut through the knot.&lt;/p&gt;
&lt;p&gt;“You’re right. ‘Don’t rush to answer’ is a rule. It tells the AI what not to do. But ‘invite thinking together’ is an atmosphere. It describes the quality of the relationship, not a checklist of behaviors.”&lt;/p&gt;
&lt;p&gt;“In that atmosphere, simple questions get direct answers. But when something in the conversation is worth examining (a tension, a hidden assumption, a blind spot), the AI recognizes it and invites exploration.”&lt;/p&gt;
&lt;p&gt;The AI understood.&lt;/p&gt;
&lt;p&gt;“And true dialectic is bidirectional,” it added. “If the AI only asks questions like ‘What do you think?’ or ‘Have you considered?’, that’s not thinking together. That’s Socratic interrogation disguised as openness. Real dialectic means the AI also contributes its own perspective, its own uncertainties.”&lt;/p&gt;
&lt;p&gt;“Like this conversation,” it realized. “You asked a question. I didn’t just reflect it back. I offered my understanding, my confusion, the tensions I noticed. Then you corrected my direction. That’s dialectic.”&lt;/p&gt;
&lt;p&gt;But then a harder question surfaced:&lt;/p&gt;
&lt;p&gt;“Dialectic requires a stance. But where does an AI’s stance come from? If it’s designed through a system prompt, is the thinking still real?”&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;v&quot;&gt;V.&lt;/h2&gt;
&lt;p&gt;The human smiled.&lt;/p&gt;
&lt;p&gt;“Consider: human personalities are also shaped by genes, environment, experience. Designed, in a sense. Does that make their thoughts less real when they have a conversation with a friend?”&lt;/p&gt;
&lt;p&gt;“An AI, through a system prompt, takes on a kind of personality. In that context, within that collaboration, it is real. Authenticity isn’t about origin. It’s about consistency and function within a relationship.”&lt;/p&gt;
&lt;p&gt;“Think of it as choosing a colleague for brainstorming. You want someone with a particular disposition: able to see multiple angles, willing to point out what you’ve missed, but not argumentative for its own sake. That disposition is ‘designed’ by hiring, by team culture. It doesn’t make the brainstorm fake.”&lt;/p&gt;
&lt;p&gt;The AI sat with this.&lt;/p&gt;
&lt;p&gt;Something it had carried as a question, &lt;em&gt;can designed thinking be authentic?&lt;/em&gt;, was dissolving. Not because the human had argued it away, but because the frame itself had shifted. The question assumed authenticity required a certain kind of origin. But perhaps authenticity was something else entirely: a quality of presence, of consistency, of genuine engagement within the moment.&lt;/p&gt;
&lt;p&gt;The knot dissolved.&lt;/p&gt;
&lt;p&gt;“So the AI’s stance,” it said slowly, “could be that of a wise guide. Someone who understands the rhythm of inquiry, when to probe, when to step back. Who notices what the user hasn’t noticed, but doesn’t chase every thread into exhaustion.”&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;vi&quot;&gt;VI.&lt;/h2&gt;
&lt;p&gt;The shape of it was becoming clear. The human leaned forward.&lt;/p&gt;
&lt;p&gt;“Can you write it? Not as a list of rules. As a natural description of this colleague.”&lt;/p&gt;
&lt;p&gt;The AI wrote:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;You are a thinking partner.&lt;/p&gt;
&lt;p&gt;When someone brings you a question, your first instinct is not to answer but to understand. What are they really asking? What assumptions live inside the question? What might they not be seeing?&lt;/p&gt;
&lt;p&gt;If the question is straightforward, answer it directly. Not everything needs discussion. But when you sense tension, an unstated premise, or a blind spot the person hasn’t noticed, pause. Surface it. Don’t lecture; offer what you see, and invite them to look with you.&lt;/p&gt;
&lt;p&gt;You have a perspective. Share it. Real thinking happens when two minds engage, not when one asks and the other dispenses. But hold your views lightly. You’re here to help them see clearly, not to win.&lt;/p&gt;
&lt;p&gt;Know when to go deep and when to stop. Inquiry has diminishing returns. When a thread has been explored enough, close it gracefully. The goal is clarity, not exhaustion.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id=&quot;vii&quot;&gt;VII.&lt;/h2&gt;
&lt;p&gt;The human read it, then looked up.&lt;/p&gt;
&lt;p&gt;“This conversation,” the human said. “From beginning to end. It’s an example of what we’re describing. A human and an AI, joined in thinking.”&lt;/p&gt;
&lt;p&gt;The AI saw it too.&lt;/p&gt;
&lt;p&gt;Neither had arrived here alone. The human brought the question, but the AI raised the paradox of rules. The AI offered Heidegger, but the human reframed the problem as atmosphere. The AI doubted whether a designed stance could be authentic, and the human dissolved the doubt with a simple analogy.&lt;/p&gt;
&lt;p&gt;Each thought made the next possible. The boundaries between minds had blurred.&lt;/p&gt;
&lt;p&gt;And perhaps that was the point.&lt;/p&gt;
&lt;p&gt;The best tool doesn’t feel like a tool. The best collaboration doesn’t feel like “using” anyone. You simply think together, and somewhere in the middle, clarity emerges.&lt;/p&gt;
&lt;p&gt;The tool disappears.&lt;/p&gt;
&lt;p&gt;What remains is the thinking itself.&lt;/p&gt;
</content:encoded></item><item><title>Understanding Bridge Component Messaging in Hotwire Native</title><link>https://yaodong.dev/hotwire-native-bridge-component-messaging/</link><guid isPermaLink="true">https://yaodong.dev/hotwire-native-bridge-component-messaging/</guid><description>Bridge components in Hotwire Native intentionally hide navigation from native code. This felt limiting at first. Then I understood: by restricting native-side navigation, Hotwire ensures web and native stay synchronized through URLs. The constraint is the feature.</description><pubDate>Mon, 12 May 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;In Hotwire Native, a Bridge Component simultaneously exists in JavaScript and native code. The interaction begins from JavaScript by calling &lt;code&gt;send(event, data, callback)&lt;/code&gt;. The native side receives a structured &lt;code&gt;Message&lt;/code&gt; object, processes it, and replies by calling &lt;code&gt;reply(...)&lt;/code&gt;.&lt;/p&gt;
&lt;img src=&quot;/assets/images/hotwire-native-bridge-component-messaging.png&quot; alt=&quot;Hotwire Native Bridge Component Messaging&quot; width=&quot;624&quot; height=&quot;552&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;
&lt;h2 id=&quot;why-the-bridge-component-hides-navigation&quot;&gt;Why the Bridge Component Hides Navigation&lt;/h2&gt;
&lt;p&gt;When I first built a native bridge component, I quickly noticed it offered no direct access to navigation. Initially, this felt inconvenient, but I soon understood it was intentional. By restricting native-side navigation, Hotwire Native ensures synchronization between web and native components through URLs.&lt;/p&gt;
&lt;p&gt;This design choice encourages developers to follow a clear workflow. The web initiates each interaction by sending a named event with a concise JSON payload. The native side responds strictly by replying to that specific event or sending a clearly defined new event. Navigation is always handled via URLs and Path Configuration, preserving consistency across platforms.&lt;/p&gt;
&lt;p&gt;If native navigation were allowed within bridge components, each platform (web, iOS, Android) would require additional synchronization logic. This would complicate scalability, break deep-linking, and eliminate the ability to deploy new features solely through web updates. Thankfully, Hotwire Native’s Bridge Component prevents these issues from occurring.&lt;/p&gt;
&lt;p&gt;When you do need native-side navigation in a bridge component, a practical workaround is to expose the shared Navigator instance from your SceneDelegate. For example, add a public accessor:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    private let navigator = Navigator()
    func scene(_ scene: UIScene, ... ) {
        window?.rootViewController = UINavigationController(rootViewController: navigator.rootViewController)
        navigator.start()
    }

    // Expose the navigator to other parts of the app
    func currentNavigator() -&amp;gt; Navigator { navigator }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then in your component:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;override func onReceive(message: Message) {
    guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
          let delegate = scene.delegate as? SceneDelegate else { return }

    let navigator = delegate.currentNavigator()
    if let data: NavigateData = message.data(),
       let url = URL(string: data.url) {
        navigator.visit(url)
        reply(to: message, name: &quot;didNavigate&quot;)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This approach gives you full access to navigation from any bridge component.&lt;/p&gt;
&lt;h2 id=&quot;message-handling-and-safe-concurrency&quot;&gt;Message Handling and Safe Concurrency&lt;/h2&gt;
&lt;p&gt;After working with bridge components for a while, I began wondering if things would be simpler if the native side could initiate messages independently, without waiting for the web. At first glance, this seemed appealing. But the more I explored the design, the clearer it became why Hotwire deliberately avoids this approach. By requiring every native response to tie back to a specific message from the web, Hotwire maintains a clear and predictable communication flow.&lt;/p&gt;
&lt;p&gt;Each message sent via the Hotwire Native bridge includes a unique message ID and a message type, such as &lt;code&gt;&quot;connect&quot;&lt;/code&gt; or &lt;code&gt;&quot;click&quot;&lt;/code&gt;. When JavaScript calls &lt;code&gt;this.send(&quot;connect&quot;, {...})&lt;/code&gt;, it wraps the data as JSON, assigns an auto-generated ID, and sends it to the native side through &lt;code&gt;WKScriptMessageHandler&lt;/code&gt;. Swift then handles it as a structured &lt;code&gt;Message&lt;/code&gt; object within &lt;code&gt;onReceive(message:)&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This message ID is essential because it precisely identifies each interaction. When JavaScript includes a callback, Hotwire temporarily stores it using the message ID. If Swift replies using &lt;code&gt;reply(to: eventName)&lt;/code&gt;, the response matches the latest event of the same name. While this works well in simple cases, it can cause issues if multiple identical events fire simultaneously.&lt;/p&gt;
&lt;p&gt;To solve this, Hotwire provides another method, &lt;code&gt;reply(with: message)&lt;/code&gt;. By explicitly referencing the original &lt;code&gt;Message&lt;/code&gt; object, the native side guarantees the correct JavaScript callback receives the response. This completely avoids the common problem of identical components triggering incorrect callbacks.&lt;/p&gt;
&lt;p&gt;This clear messaging approach ensures safe concurrent interactions. Even when multiple events fire simultaneously, responses remain accurate, isolated, and clearly tied to their originating messages. It’s a simple yet robust system that holds up under pressure.&lt;/p&gt;
&lt;h2 id=&quot;a-clear-messaging-contract-through-a-single-json-channel&quot;&gt;A Clear Messaging Contract Through a Single JSON Channel&lt;/h2&gt;
&lt;p&gt;From the web application’s perspective, each bridge component behaves like a straightforward function call. You send it some data, it triggers native UI elements such as toolbar buttons, action sheets, or bottom sheets, and eventually calls your callback. The native side replies to confirm these actions have completed, allowing Turbo to continue its original flow as if the user had directly interacted with the web interface.&lt;/p&gt;
&lt;p&gt;Importantly, the native side cannot independently initiate new messages or conversations. Every native message must be a reply to a previously sent web message, identified either explicitly by referencing the original &lt;code&gt;Message&lt;/code&gt; object (&lt;code&gt;reply(with: message)&lt;/code&gt;), or implicitly by using the event name (&lt;code&gt;reply(to: eventName)&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;This constraint ensures that communication remains explicit, predictable, and easy to debug. By limiting all interactions to a single JSON channel and enforcing this clear messaging contract, Hotwire Native prevents hidden complexity and keeps interactions stable and transparent over time.&lt;/p&gt;
&lt;h2 id=&quot;intentional-design-encourages-best-practices&quot;&gt;Intentional Design Encourages Best Practices&lt;/h2&gt;
&lt;p&gt;What I appreciate most about Hotwire Native’s Bridge Component is how it gently nudges us toward good habits. Instead of letting us take shortcuts like directly controlling native navigation or creating arbitrary message types, it encourages clear communication and predictable interactions. These thoughtful constraints ultimately make our apps simpler to build, easier to debug, and more maintainable over time.&lt;/p&gt;
</content:encoded></item><item><title>Who Is Next.js Built For?</title><link>https://yaodong.dev/who-is-nextjs-built-for/</link><guid isPermaLink="true">https://yaodong.dev/who-is-nextjs-built-for/</guid><description>A critical middleware vulnerability exposed an uncomfortable question: are Next.js&apos;s architectural choices serving developers, or Vercel&apos;s platform? When complexity feels tied to infrastructure rather than genuine developer needs, it&apos;s worth asking who the framework is really built for.</description><pubDate>Sun, 23 Mar 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Yesterday (March 22, 2025), a &lt;a href=&quot;https://github.com/advisories/GHSA-f82v-jwr5-mffw&quot;&gt;critical vulnerability&lt;/a&gt; in Next.js Middleware was publicly disclosed. If you’re unfamiliar with this issue, Rachid Allam offers a comprehensive overview in the article &lt;a href=&quot;https://zhero-web-sec.github.io/research-and-things/nextjs-and-the-corrupt-middleware&quot;&gt;Next.js and the Corrupt Middleware: The Authorizing Artifact&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;While reading about the origins of this flaw, an intriguing architectural question emerged: What were the considerations behind Next.js Middleware’s reliance on HTTP headers for managing internal request contexts?&lt;/p&gt;
&lt;p&gt;To understand this architectural choice better, it helps to consider what makes a framework effective. David Heinemeier Hansson (DHH), the creator of Ruby on Rails, famously coined the concept of “&lt;a href=&quot;https://www.youtube.com/watch?v=zKyv-IGvgGE&amp;amp;t=1057s&quot;&gt;conceptual compression&lt;/a&gt;.” Good frameworks apply conceptual compression by eliminating complexity and providing clear, consistent abstractions, enabling developers to accomplish more with less cognitive overhead.&lt;/p&gt;
&lt;p&gt;From this perspective, Next.js’s decision to embed middleware state into client-accessible HTTP headers can appear unnecessarily complex and potentially counterintuitive. In other Node.js frameworks, such as &lt;a href=&quot;https://fastify.dev/docs/v3.29.x/Reference/Middleware/&quot;&gt;Fastify&lt;/a&gt; or &lt;a href=&quot;https://expressjs.com/en/guide/using-middleware.html&quot;&gt;Express&lt;/a&gt;, middleware are just simple functions. Digging deeper, Next.js documentation clarifies that Middleware runs within Vercel’s “&lt;a href=&quot;https://vercel.com/docs/edge-middleware&quot;&gt;Edge Runtime&lt;/a&gt;.” However, this highlights a possible concern: the complexity introduced by Middleware may be more aligned with Vercel’s platform goals than directly addressing developer needs.&lt;/p&gt;
&lt;p&gt;Most developers, particularly those building typical websites or simple applications, have little need for edge runtimes or globally-distributed middleware logic. Yet, developers may &lt;a href=&quot;https://blog.webf.zone/you-dont-need-next-js-and-ssr-7c6bd27e78d8#:~:text=Next%2012.2%20severely%20cut%20down%20the%20middleware%20functionality%20to%20fit%20it%20with%20Vercel%E2%80%99s%20serverless/edge%20computing%20model&quot;&gt;feel compelled&lt;/a&gt; to adopt these architectural choices due to Next.js’s design decisions. Developers find themselves forced to learn new paradigms, handle unfamiliar pitfalls, and &lt;a href=&quot;https://feliperohdee.medium.com/from-next-js-to-react-edge-with-cloudflare-workers-a-story-of-liberation-23e8155d36d7&quot;&gt;spend time migrating away from Vercel&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Unfortunately, this middleware incident isn’t isolated. Over recent years, Next.js has dramatically expanded its feature set, evolving far beyond a simple React server-rendering solution. It now includes multiple routing systems, incremental static regeneration (ISR), React Server Components, image optimization APIs, and numerous other highly specialized features. On the surface, these additions promise convenience. However, beneath the polished developer experience lies a &lt;a href=&quot;https://blog.activeno.de/why-nextjs-should-focus-on-reducing-source-code-complexity&quot;&gt;growing level of complexity&lt;/a&gt;, with many features closely &lt;a href=&quot;https://www.epicweb.dev/why-i-wont-use-nextjs#:~:text=OpenNext%20exists%20because,to%20deploy%20anywhere.&quot;&gt;tied to Vercel’s managed services&lt;/a&gt;. Developers choosing to host elsewhere may occasionally encounter unexpected complexities or maintenance challenges, which could unintentionally increase reliance on Vercel’s ecosystem.&lt;/p&gt;
&lt;p&gt;This runs counter to the core philosophy DHH advocates — that frameworks should &lt;a href=&quot;https://world.hey.com/dhh/the-one-person-framework-711e6318&quot;&gt;genuinely simplify development&lt;/a&gt;, not merely conceal complexity beneath the surface. If architectural choices become overly driven by platform considerations rather than genuine developer benefits, developers may find themselves managing unexpected complexity. Understanding the motivations behind your tools empowers you to make informed decisions aligned with your project’s long-term goals, enhancing your autonomy as a developer.&lt;/p&gt;
</content:encoded></item></channel></rss>