<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Joshua Bellew&apos;s Blog</title><description>Technical deep dives on distributed systems, reliability engineering, and AI agent architectures. Written by Joshua Bellew, Technical Lead with 6+ years building high-scale backend systems.</description><link>https://joshuabellew.com/</link><item><title>Build a Basic AI Agent in TypeScript, Part 4: Production Readiness and Operational Guardrails</title><link>https://joshuabellew.com/posts/agent-series-part-4-production-readiness/</link><guid isPermaLink="true">https://joshuabellew.com/posts/agent-series-part-4-production-readiness/</guid><description>A non-code guide to deploying agents safely with approval flows, observability, evaluation loops, and operational ownership</description><pubDate>Mon, 02 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Overview&lt;/h2&gt;
&lt;p&gt;Part 1 covered the loop, Part 2 covered tool calling, and Part 3 covered multi-step skill orchestration. This final part is about operating an agent in production, not just getting good outputs in a demo. The goal is simple, define guardrails before the agent can touch real systems, real data, and real users.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TLDR:&lt;/strong&gt; Production agent work is operational design. Define what the agent can execute, require approval for high-impact actions, log decision paths, evaluate route quality over time, and make ownership explicit before launch.&lt;/p&gt;
&lt;h2&gt;Full Series&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;/posts/agent-series-part-1-basic-loop-typescript&quot;&gt;Part 1, The Core Loop&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/posts/agent-series-part-2-custom-tool-routing-typescript&quot;&gt;Part 2, Tool Calling and LLM Intent Routing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/posts/agent-series-part-3-multi-step-orchestration-typescript&quot;&gt;Part 3, Multi-Step Skill Orchestration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Part 4, Production Readiness and Operational Guardrails (you are here)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Note: This is not an exhaustive guide to distributed systems. It is a practical write-up based on personal tinkering with multi-step orchestration agents.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;I have been exploring agents and LLMs since ChatGPT first launched, and I have spent a lot of time building small agents and multi-step orchestrations. There are excellent models available now, but moving an agent from a simple loop to something production-ready is a big jump. That jump is less about model quality, and more about applying the same software engineering basics we already rely on in distributed systems.&lt;/p&gt;
&lt;h2&gt;Sections&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#why-production-readiness-is-a-different-problem&quot;&gt;Why Production Readiness Is a Different Problem&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#workflow-orchestration-vs-single-step-skill-execution&quot;&gt;Workflow Orchestration vs Single-Step Skill Execution&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#safety-boundaries-and-action-classification&quot;&gt;Safety Boundaries and Action Classification&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#security-access-control-and-pii-handling&quot;&gt;Security, Access Control, and PII Handling&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#human-approval-gates-for-high-impact-actions&quot;&gt;Human Approval Gates for High-Impact Actions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#idempotency-and-duplicate-action-prevention&quot;&gt;Idempotency and Duplicate Action Prevention&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#observability-primitives-you-need-on-day-one&quot;&gt;Observability Primitives You Need on Day One&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#evaluation-loops-replay-route-accuracy-and-drift&quot;&gt;Evaluation Loops: Replay, Route Accuracy, and Drift&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#reliability-policies-retries-timeouts-and-rate-limits&quot;&gt;Reliability Policies: Retries, Timeouts, and Rate Limits&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#operational-readiness-runbooks-alerts-and-rollback&quot;&gt;Operational Readiness: Runbooks, Alerts, and Rollback&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#ownership-model-and-cross-functional-rollout&quot;&gt;Ownership Model and Cross-Functional Rollout&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#production-readiness-checklist&quot;&gt;Production Readiness Checklist&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#final-takeaway&quot;&gt;Final Takeaway&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#official-references&quot;&gt;Official References&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Why Production Readiness Is a Different Problem&lt;/h2&gt;
&lt;p&gt;Prototype quality and production quality optimize for different outcomes.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Prototypes optimize for speed and learning.&lt;/li&gt;
&lt;li&gt;Production systems optimize for safe, repeatable outcomes.&lt;/li&gt;
&lt;li&gt;In production, failure handling is part of the feature.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Example: a demo may show one successful tool call. Production requires retries, timeout limits, approvals, and rollback when that same call fails or duplicates.&lt;/p&gt;
&lt;p&gt;A working demo is useful evidence, but it is not an operational design.&lt;/p&gt;
&lt;h2&gt;Workflow Orchestration vs Single-Step Skill Execution&lt;/h2&gt;
&lt;p&gt;Single-step skill execution handles one bounded task, for example, fetch one record and return it.&lt;/p&gt;
&lt;p&gt;Workflow orchestration coordinates multiple dependent steps across systems. Each step can fail, retry, or require escalation.&lt;/p&gt;
&lt;p&gt;The distinction matters because risk compounds across steps.&lt;/p&gt;
&lt;p&gt;Example:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Step 1 reads account status.&lt;/li&gt;
&lt;li&gt;Step 2 writes a billing adjustment.&lt;/li&gt;
&lt;li&gt;Step 3 sends an external email confirmation.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each step is understandable alone. The full chain can create user impact, billing impact, and support impact.&lt;/p&gt;
&lt;h2&gt;Safety Boundaries and Action Classification&lt;/h2&gt;
&lt;p&gt;Before launch, classify actions by impact.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Read-only actions, low risk.&lt;/li&gt;
&lt;li&gt;Internal writes, medium risk.&lt;/li&gt;
&lt;li&gt;External writes, billing, identity, or irreversible actions, high risk.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This classification should drive policy, not model preference. The model can suggest an action. Policy decides whether execution is allowed.&lt;/p&gt;
&lt;h2&gt;Security, Access Control, and PII Handling&lt;/h2&gt;
&lt;p&gt;Security boundaries should be explicit before launch.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Apply least privilege to tool access.&lt;/li&gt;
&lt;li&gt;Scope tool permissions by role, tenant, and environment.&lt;/li&gt;
&lt;li&gt;Enforce authorization checks in the runtime, not only in prompts.&lt;/li&gt;
&lt;li&gt;Deny by default, then allowlist required capabilities.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;PII handling matters just as much as execution control.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Redact or tokenize sensitive fields before logs are written.&lt;/li&gt;
&lt;li&gt;Keep replay datasets sanitized and access-controlled.&lt;/li&gt;
&lt;li&gt;Set retention rules for transcripts and execution traces.&lt;/li&gt;
&lt;li&gt;Avoid copying raw PII into prompts unless required for task completion.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Example: if a support transcript includes full card details and you replay it in a lower environment, you create a second security problem while debugging the first one.&lt;/p&gt;
&lt;p&gt;Observability and replay need controls. Logging everything without controls creates new risk.&lt;/p&gt;
&lt;h2&gt;Human Approval Gates for High-Impact Actions&lt;/h2&gt;
&lt;p&gt;Human approval is a control for high-consequence operations.&lt;/p&gt;
&lt;p&gt;Good approval gates include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Clear summary of intended action.&lt;/li&gt;
&lt;li&gt;Input values and affected targets.&lt;/li&gt;
&lt;li&gt;Reason the model selected this path.&lt;/li&gt;
&lt;li&gt;Explicit approve or reject outcome with audit trace.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Example: &quot;Refund $2,400 to account 9182&quot; should never execute from model confidence alone. It should require explicit approval with a recorded decision.&lt;/p&gt;
&lt;h2&gt;Idempotency and Duplicate Action Prevention&lt;/h2&gt;
&lt;p&gt;Agents run in distributed systems where retries or duplicate execution can and will happen.&lt;/p&gt;
&lt;p&gt;Idempotency keys turn repeated requests into one logical operation.&lt;/p&gt;
&lt;p&gt;Example: if a network timeout triggers a retry after a charge request, idempotency prevents a second charge.&lt;/p&gt;
&lt;h2&gt;Observability Primitives You Need on Day One&lt;/h2&gt;
&lt;p&gt;At minimum, each execution should emit:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;request id and correlation id,&lt;/li&gt;
&lt;li&gt;selected skill or handler,&lt;/li&gt;
&lt;li&gt;tool calls made,&lt;/li&gt;
&lt;li&gt;latency,&lt;/li&gt;
&lt;li&gt;token cost,&lt;/li&gt;
&lt;li&gt;final status.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These fields answer three operational questions quickly: what happened, where it failed, and who owns the fix.&lt;/p&gt;
&lt;h2&gt;Evaluation Loops: Replay, Route Accuracy, and Drift&lt;/h2&gt;
&lt;p&gt;Agent quality can degrade quietly if behavior is not evaluated over time.&lt;/p&gt;
&lt;p&gt;A lightweight evaluation loop can start with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;transcript replay tests,&lt;/li&gt;
&lt;li&gt;expected route assertions,&lt;/li&gt;
&lt;li&gt;periodic route accuracy tracking,&lt;/li&gt;
&lt;li&gt;review of false-positive and false-negative routing outcomes.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Example: if route accuracy drops after a prompt update, the issue might be policy wording, not model capability.&lt;/p&gt;
&lt;p&gt;Evaluation is for model quality, policy quality, and workflow design quality.&lt;/p&gt;
&lt;h2&gt;Reliability Policies: Retries, Timeouts, and Rate Limits&lt;/h2&gt;
&lt;p&gt;Reliability policies should be explicit and per step.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Retry only transient failures.&lt;/li&gt;
&lt;li&gt;Apply timeout budgets per operation.&lt;/li&gt;
&lt;li&gt;Enforce rate limits at skill and system boundaries.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Example: without a timeout budget, one slow dependency can block worker capacity and cascade latency across unrelated requests.&lt;/p&gt;
&lt;p&gt;These controls are baseline protection against runaway failure modes.&lt;/p&gt;
&lt;h2&gt;Operational Readiness: Runbooks, Alerts, and Rollback&lt;/h2&gt;
&lt;p&gt;Production readiness requires operational expertise.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Define alert thresholds for failure rate and latency.&lt;/li&gt;
&lt;li&gt;Maintain runbooks with triage and escalation paths.&lt;/li&gt;
&lt;li&gt;Predefine rollback strategy for bad prompts, bad routes, or bad policy changes.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Example: if a routing change sends 30 percent of requests to the wrong skill, rollback should be one documented action, not an incident-time debate.&lt;/p&gt;
&lt;h2&gt;Ownership Model and Cross-Functional Rollout&lt;/h2&gt;
&lt;p&gt;Agent systems span product, engineering, operations, security, and support.&lt;/p&gt;
&lt;p&gt;Set clear ownership boundaries:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Who owns routing logic.&lt;/li&gt;
&lt;li&gt;Who owns policy changes.&lt;/li&gt;
&lt;li&gt;Who approves high-risk skill updates.&lt;/li&gt;
&lt;li&gt;Who handles incidents.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Clear ownership shortens incident response and reduces policy drift.&lt;/p&gt;
&lt;h2&gt;Production Readiness Checklist&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Workflow scope and action risk classes are documented.&lt;/li&gt;
&lt;li&gt;Human approval exists for high-impact actions.&lt;/li&gt;
&lt;li&gt;Tool access is scoped by role and least-privilege policy.&lt;/li&gt;
&lt;li&gt;Idempotency strategy is implemented for side-effecting operations.&lt;/li&gt;
&lt;li&gt;Observability fields are emitted for every execution.&lt;/li&gt;
&lt;li&gt;Logging and replay datasets are sanitized for PII.&lt;/li&gt;
&lt;li&gt;Replay-based evaluation runs on real transcripts.&lt;/li&gt;
&lt;li&gt;Retry, timeout, and rate-limit policy is defined per step.&lt;/li&gt;
&lt;li&gt;Alerting, runbooks, and rollback paths are tested.&lt;/li&gt;
&lt;li&gt;Ownership and escalation paths are clear.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Final Takeaway&lt;/h2&gt;
&lt;p&gt;Operating agents in production follows the same principles as any distributed system: observability, guardrails, reliability policy, rollback planning, and clear ownership.&lt;/p&gt;
&lt;h2&gt;Official References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://ampcode.com/notes/how-to-build-an-agent&quot;&gt;Amp, How to Build an Agent&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.anthropic.com/research/building-effective-agents&quot;&gt;Anthropic, Building Effective Agents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://platform.claude.com/docs/en/agent-sdk/skills&quot;&gt;Anthropic docs, Agent SDK Skills&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://platform.claude.com/docs/en/agents-and-tools/tool-use/implement-tool-use&quot;&gt;Anthropic docs, Tool use&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>Book Review: Latency by Pekka Enberg</title><link>https://joshuabellew.com/posts/latency-book-review/</link><guid isPermaLink="true">https://joshuabellew.com/posts/latency-book-review/</guid><description>A deep dive into core latency concepts and distributed systems techniques</description><pubDate>Tue, 25 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Overview&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://www.manning.com/books/latency&quot;&gt;Latency: Reduce delay in software systems&lt;/a&gt; by &lt;a href=&quot;https://x.com/penberg&quot;&gt;Pekka Enberg&lt;/a&gt; is a comprehensive guide that bridges latency optimization with distributed systems design. The book demonstrates how core latency concepts are tightly interwoven with what many consider best practice distributed systems techniques, revealing connections that aren&apos;t immediately obvious.&lt;/p&gt;
&lt;h2&gt;What Clicked&lt;/h2&gt;
&lt;p&gt;The book starts slow (no pun intended) but gradually builds momentum, introducing latency techniques that overlap and closely intertwine with fundamental distributed systems concepts. What surprised me most was discovering how many &quot;obvious&quot; best practices are actually deeply connected to latency optimization principles.&lt;/p&gt;
&lt;p&gt;The progression from high-level system design to CPU-level mechanics strikes the perfect balance, deep enough to be valuable without getting lost in either abstraction or minutiae.&lt;/p&gt;
&lt;h2&gt;Practical Value&lt;/h2&gt;
&lt;h3&gt;Standout Sections&lt;/h3&gt;
&lt;p&gt;The caching chapter provided exceptional clarity with easy-to-grasp explanations. While the middle sections initially felt disjointed due to what at times felt like a lesson in distributed systems instead of latency, persevering paid off as everything connected together when I realized the author was making it extremely obvious how they are tightly coupled and overlap with latency techniques.&lt;/p&gt;
&lt;p&gt;The book covers impressive ground:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;System colocation&lt;/strong&gt; through &lt;strong&gt;CPU-level caching&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CPU cache coordination&lt;/strong&gt; and &lt;strong&gt;multi-cache synchronization&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Locking mechanisms&lt;/strong&gt; and concurrency patterns&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Latency hiding techniques&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each topic provides enough depth to understand the concepts and research further if needed.&lt;/p&gt;
&lt;h2&gt;What Makes This Book Different&lt;/h2&gt;
&lt;p&gt;Most technical books teach the &quot;what&quot; and &quot;how&quot; but skip the crucial &quot;when&quot;. This book excels at explaining when to apply specific techniques, making it immediately practical rather than just theoretical.&lt;/p&gt;
&lt;p&gt;This focus on context and trade-offs transforms it from a reference manual into a decision-making guide that engineers at any level would benefit from, either from an education point of view or ability to apply at their place of work.&lt;/p&gt;
&lt;h2&gt;A Note on Rust&lt;/h2&gt;
&lt;p&gt;The code examples use Rust, which makes perfect sense for demonstrating locks, thread spawning, and low-level concurrency patterns. Coming from a TypeScript background with limited Rust experience (just the online Rust book and Rustlings), the examples required concentration to fully grasp.&lt;/p&gt;
&lt;p&gt;For readers unfamiliar with Rust, I would expect this to be a lot harder to grasp if you want to follow along with the code examples. That said, Rust is the right choice here, although Go would be a great language for demonstrating these same concepts and perhaps a lot easier to read for those coming from more dynamic languages.&lt;/p&gt;
&lt;h2&gt;Final Thoughts&lt;/h2&gt;
&lt;p&gt;This is a book I&apos;ll read again and regularly return to for refreshing specific topics. It&apos;s valuable both as a comprehensive learning resource and a reference guide for specific latency optimization techniques.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;strong&gt;Rating&lt;/strong&gt;: 5/5&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Would recommend for&lt;/strong&gt;: Engineers working on distributed systems, performance-critical applications, or anyone wanting to deeply understand the relationship between latency and system design. Some programming experience required; Rust familiarity helpful but not essential.&lt;/p&gt;
</content:encoded></item><item><title>Why I Write About Systems That Break</title><link>https://joshuabellew.com/posts/my-first-blog-post/</link><guid isPermaLink="true">https://joshuabellew.com/posts/my-first-blog-post/</guid><description>The reason this blog exists, what I write about, and who it is for.</description><pubDate>Tue, 30 Sep 2025 23:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Why this blog exists&lt;/h2&gt;
&lt;p&gt;Production systems fail in ways that documentation never covers. Over 6+ years of building and operating distributed systems, the most valuable lessons I have learned came from real outages, real architectural trade-offs, and real debugging sessions. This blog is where I write those lessons down.&lt;/p&gt;
&lt;p&gt;I cover three areas:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Distributed systems and reliability.&lt;/strong&gt; Deep dives into outages and RCAs from companies like AWS and Cloudflare, breaking down what went wrong, why recovery was hard, and what we can take from it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AI agent architectures.&lt;/strong&gt; Building AI agents from scratch in TypeScript, covering tool routing, multi-step orchestration, and production readiness.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Backend engineering at scale.&lt;/strong&gt; The decisions and patterns that matter when your system handles millions of users, processes payments globally, or needs to stay up at 3am.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Most engineering blogs stay surface-level. I aim for the opposite. Every post goes deep enough that you walk away understanding not just what happened, but why.&lt;/p&gt;
&lt;h2&gt;Blogs I read and recommend&lt;/h2&gt;
&lt;p&gt;These writers set the standard for the kind of technical depth I am aiming for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://mitchellh.com/&quot;&gt;Mitchell Hashimoto&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://eatonphil.com/&quot;&gt;Phil Eaton&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://thorstenball.com/register-spill/&quot;&gt;Thorsten Ball&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://matklad.github.io/&quot;&gt;Matklad&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://planetscale.com/blog&quot;&gt;Planetscale&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://zed.dev/blog&quot;&gt;Zed&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>Build a Basic AI Agent in TypeScript, Part 3: Multi-Step Skill Orchestration</title><link>https://joshuabellew.com/posts/agent-series-part-3-multi-step-orchestration-typescript/</link><guid isPermaLink="true">https://joshuabellew.com/posts/agent-series-part-3-multi-step-orchestration-typescript/</guid><description>Move beyond single tool calls by introducing skills, simple orchestration, and patterns for safe, observable, controllable execution</description><pubDate>Fri, 30 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Overview&lt;/h2&gt;
&lt;p&gt;Part 1 established the core loop, and Part 2 added model-driven tool calling. Part 3 focuses on the next mental model shift: from one tool at a time to coordinated skills with clear execution boundaries. The goal is not to build a full production platform. It is to understand the core concepts you need before scaling complexity.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TLDR:&lt;/strong&gt; Tools execute one action. Skills coordinate related actions. A useful agent needs simple routing plus lightweight patterns for safe, observable, and controllable execution.&lt;/p&gt;
&lt;h2&gt;Full Series&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;/posts/agent-series-part-1-basic-loop-typescript&quot;&gt;Part 1, The Core Loop&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/posts/agent-series-part-2-custom-tool-routing-typescript&quot;&gt;Part 2, Tool Calling and LLM Intent Routing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Part 3, Multi-Step Skill Orchestration (you are here)&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/posts/agent-series-part-4-production-readiness&quot;&gt;Part 4, Production Readiness and Operational Guardrails&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Sections&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#what-part-3-adds-beyond-part-2&quot;&gt;What Part 3 Adds Beyond Part 2&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#core-concept-1-skills-vs-tools&quot;&gt;Core Concept 1, Skills vs Tools&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#claude-skills-example-how-skills-plug-into-the-loop&quot;&gt;Claude Skills Example, How Skills Plug Into the Loop&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#core-concept-2-simple-skill-routing&quot;&gt;Core Concept 2, Simple Skill Routing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#core-concept-3-basic-multi-step-orchestration&quot;&gt;Core Concept 3, Basic Multi-Step Orchestration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#core-concept-4-safe-observable-controllable-execution&quot;&gt;Core Concept 4, Safe Observable Controllable Execution&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#typescript-pseudocode-multi-step-skills-in-the-agent-loop&quot;&gt;TypeScript Pseudocode, Multi-Step Skills in the Agent Loop&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#common-pitfalls&quot;&gt;Common Pitfalls&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#lessons-learned&quot;&gt;Lessons Learned&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#series-wrap-up&quot;&gt;Series Wrap-Up&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#official-references&quot;&gt;Official References&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;What Part 3 Adds Beyond Part 2&lt;/h2&gt;
&lt;p&gt;Part 2 solved one key problem: how to let the model request a tool and return results in a loop.&lt;/p&gt;
&lt;p&gt;Part 3 solves a different problem: how to organize many tool-capable behaviors without turning runtime logic into spaghetti.&lt;/p&gt;
&lt;p&gt;In practical terms:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Part 2 answer: &quot;Can the model call a tool?&quot;&lt;/li&gt;
&lt;li&gt;Part 3 answer: &quot;Can the runtime choose and coordinate the right skill safely?&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Core Concept 1, Skills vs Tools&lt;/h2&gt;
&lt;p&gt;A tool is a single deterministic capability, for example &lt;code&gt;get_weather(location)&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;A skill is a higher-level unit of behavior that can:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;decide which tool(s) to use,&lt;/li&gt;
&lt;li&gt;control sequence across steps,&lt;/li&gt;
&lt;li&gt;normalize outputs for the agent response.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Think of it this way:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Tool  = one action adapter
Skill = a small workflow that may use one or more tools
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This distinction matters because users ask for outcomes, not function calls.&lt;/p&gt;
&lt;h2&gt;Claude Skills Example, How Skills Plug Into the Loop&lt;/h2&gt;
&lt;p&gt;If you want a concrete implementation model, Claude Skills are a useful reference because the pattern is explicit and easy to reason about.&lt;/p&gt;
&lt;p&gt;High-level shape:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Skills live on disk as &lt;code&gt;SKILL.md&lt;/code&gt; files.&lt;/li&gt;
&lt;li&gt;The SDK discovers them from configured locations.&lt;/li&gt;
&lt;li&gt;The model invokes them when descriptions match user intent.&lt;/li&gt;
&lt;li&gt;Your runtime still controls tool access and loop boundaries.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What a skill contains:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;my-skill/
├── SKILL.md        (required)
├── scripts/        (optional, executable helpers)
├── references/     (optional, docs loaded as needed)
└── assets/         (optional, templates, fonts, icons)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two ideas matter most for agent loops: multi-step execution and discovery.&lt;/p&gt;
&lt;h3&gt;Progressive Discovery&lt;/h3&gt;
&lt;p&gt;Skills are designed so the model can discover the right behavior without loading everything every turn:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Frontmatter in &lt;code&gt;SKILL.md&lt;/code&gt; supports discovery and routing.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;SKILL.md&lt;/code&gt; body is loaded when the skill is relevant.&lt;/li&gt;
&lt;li&gt;Linked files are opened only when needed.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This keeps context focused while still supporting deeper workflows.&lt;/p&gt;
&lt;h3&gt;Why This Helps Multi-Step Flows&lt;/h3&gt;
&lt;p&gt;For multi-step requests, skills encode repeatable workflow guidance, not only single tool calls.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Connectors and tools define what the runtime can do.&lt;/li&gt;
&lt;li&gt;Skills define how the runtime should do it across steps.&lt;/li&gt;
&lt;li&gt;Multiple skills can compose in the same session when tasks overlap.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is why skills improve consistency. The model is not re-inventing the process from scratch every prompt.&lt;/p&gt;
&lt;p&gt;Minimal &lt;code&gt;SKILL.md&lt;/code&gt; example:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
name: order-support
description: Handle order lookup, shipping-change eligibility checks, and guided next steps.
---

# Order Support Skill

## When to Use
- User asks about order status
- User asks to change shipping after checkout

## Instructions
1. Extract order id from user text.
2. Check order status.
3. Check change-window policy.
4. Ask for confirmation before submitting change requests.
5. Return a concise final response.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then wire it into a custom agent loop in TypeScript:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Startup phase, discover skills once and keep a lightweight index.
const skillIndex = loadSkillsFromFilesystem({
  cwd: &quot;/path/to/project&quot;,
  settingSources: [&quot;project&quot;, &quot;user&quot;]
});

async function runAgentLoop(): Promise&amp;lt;void&amp;gt; {
  while (true) {
    const userText = await readUserInput();
    if (!userText || userText.trim() === &quot;&quot;) break;

    // Build context with conversation history, tool definitions, and skill metadata.
    const plan = await llmPlan({
      messages: conversation,
      tools: toolDefinitions,
      skillMetadata: skillIndex.metadata
    });

    // Progressive discovery, load the full skill only when selected.
    const activeSkill =
      plan.type === &quot;use_skill&quot;
        ? skillIndex.loadFullSkill(plan.skillName)
        : null;

    // Execute tool calls with runtime controls.
    const result = await executePlan({
      plan,
      activeSkill,
      toolRegistry,
      maxSteps: 6,
      requireConfirmationFor: [&quot;submit_change_request&quot;]
    });

    conversation.push({ role: &quot;assistant&quot;, content: result.responseText });
    render(result.responseText);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This fits the same architecture we built in this series. The loop remains simple. Skills help the model discover and compose multi-step behavior, while your runtime still controls actual tool execution and safety policy.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Note: In Claude SDK usage, include &lt;code&gt;settingSources&lt;/code&gt; and allow the &lt;code&gt;Skill&lt;/code&gt; tool so skill discovery and invocation can happen from filesystem locations.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Core Concept 2, Simple Skill Routing&lt;/h2&gt;
&lt;p&gt;Routing is the decision of which skill handles a request.&lt;/p&gt;
&lt;p&gt;In a minimal architecture, routing can stay very simple:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The model classifies intent.&lt;/li&gt;
&lt;li&gt;The runtime maps intent to one skill.&lt;/li&gt;
&lt;li&gt;The skill executes and returns a structured result.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;When two skills overlap, use explicit precedence rules. For example, &quot;account safety&quot; can outrank &quot;general account info&quot;. This keeps behavior predictable even when user prompts are ambiguous.&lt;/p&gt;
&lt;h2&gt;Core Concept 3, Basic Multi-Step Orchestration&lt;/h2&gt;
&lt;p&gt;Orchestration means coordinating multiple internal steps to complete one user request.&lt;/p&gt;
&lt;p&gt;Example flow:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;User asks: &quot;Can you check order 4821 and tell me if I can still change shipping?&quot;
    -&amp;gt; Route to OrderSupportSkill
    -&amp;gt; Step 1: fetch order status
    -&amp;gt; Step 2: evaluate policy rules
    -&amp;gt; Step 3: return user-facing answer with next action
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the conceptual jump from Part 2. The runtime is no longer just &quot;tool call in, tool result out&quot;. It now executes a small workflow with clear boundaries.&lt;/p&gt;
&lt;h2&gt;Core Concept 4, Safe Observable Controllable Execution&lt;/h2&gt;
&lt;p&gt;This is the most important concept in this part.&lt;/p&gt;
&lt;h3&gt;Safe&lt;/h3&gt;
&lt;p&gt;Safe execution means the agent does not perform unintended or duplicated actions.&lt;/p&gt;
&lt;p&gt;Lightweight patterns:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;validate required inputs before side-effecting actions,&lt;/li&gt;
&lt;li&gt;use idempotency keys for action-like operations,&lt;/li&gt;
&lt;li&gt;require explicit confirmation for high-impact actions.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Observable&lt;/h3&gt;
&lt;p&gt;Observable execution means operators can answer, &quot;What happened, where, and why?&quot;&lt;/p&gt;
&lt;p&gt;Minimal per-request fields:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;request id,&lt;/li&gt;
&lt;li&gt;selected skill,&lt;/li&gt;
&lt;li&gt;tool calls made,&lt;/li&gt;
&lt;li&gt;latency,&lt;/li&gt;
&lt;li&gt;final status.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Controllable&lt;/h3&gt;
&lt;p&gt;Controllable execution means runtime policy stays in code, not hidden in model output.&lt;/p&gt;
&lt;p&gt;Simple controls:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;max skill steps per request,&lt;/li&gt;
&lt;li&gt;allowlist of executable tools per skill,&lt;/li&gt;
&lt;li&gt;clear fallback path when routing is uncertain.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You can implement these controls without building a large framework. Even minimal guardrails dramatically improve reliability and debuggability.&lt;/p&gt;
&lt;h2&gt;TypeScript Pseudocode, Multi-Step Skills in the Agent Loop&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; This is simplified pseudocode to illustrate the concept, not actual implementation.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;type Route = &quot;weather&quot; | &quot;order_support&quot; | &quot;fallback&quot;;

const toolRegistry = {
  lookup_order: async (args: { orderId: string }) =&amp;gt; {
    // Calls your real TypeScript tool function.
    return { ok: true, data: { id: args.orderId, status: &quot;PROCESSING&quot;, placedAt: &quot;2026-01-21T10:11:12Z&quot; } };
  },
  check_change_window: async (args: { orderStatus: string; placedAt: string }) =&amp;gt; {
    // Encapsulates policy lookup logic.
    return { ok: args.orderStatus === &quot;PROCESSING&quot;, data: { isEligible: true } };
  },
  submit_change_request: async (args: {
    orderId: string;
    newAddress: string;
    idempotencyKey: string;
  }) =&amp;gt; {
    // Side-effecting operation guarded by idempotency key.
    return { ok: true, data: { ticketId: &quot;chg_12345&quot; } };
  }
};

async function runOrderSupportSkill(input: {
  requestId: string;
  userText: string;
  userConfirmed: boolean;
  requestedAddress: string;
}): Promise&amp;lt;{ status: &quot;success&quot; | &quot;failed&quot;; responseText: string }&amp;gt; {
  const state: Record&amp;lt;string, unknown&amp;gt; = {
    userText: input.userText,
    userConfirmed: input.userConfirmed,
    requestedAddress: input.requestedAddress
  };

  // Step 1: Extract order id (could come from model extraction + validation).
  state.orderId = &quot;4821&quot;;

  // Step 2: Call lookup tool.
  const order = await toolRegistry.lookup_order({
    orderId: String(state.orderId)
  });
  if (!order.ok) {
    return { status: &quot;failed&quot;, responseText: &quot;Order lookup failed.&quot; };
  }
  state.order = order.data;

  // Step 3: Call policy tool.
  const policy = await toolRegistry.check_change_window({
    orderStatus: String((state.order as { status: string }).status),
    placedAt: String((state.order as { placedAt: string }).placedAt)
  });
  if (!policy.ok) {
    return { status: &quot;failed&quot;, responseText: &quot;Policy check failed.&quot; };
  }
  state.isEligible = policy.data?.isEligible === true;

  // Step 4: Optionally call side-effecting tool.
  if (state.isEligible === true &amp;amp;&amp;amp; state.userConfirmed === true) {
    const submit = await toolRegistry.submit_change_request({
      orderId: String(state.orderId),
      newAddress: String(state.requestedAddress),
      idempotencyKey: `${input.requestId}:shipping-change`
    });
    if (!submit.ok) {
      return { status: &quot;failed&quot;, responseText: &quot;Change request failed.&quot; };
    }
    state.changeTicket = submit.data?.ticketId;
  }

  // Step 5: Compose final response.
  const responseText =
    state.isEligible === true
      ? state.userConfirmed === true
        ? `Shipping change submitted. Ticket: ${state.changeTicket}`
        : &quot;Your order is eligible for shipping changes. Confirm to continue.&quot;
      : &quot;This order can no longer be changed under current policy.&quot;;

  return { status: &quot;success&quot;, responseText };
}

class AgentRuntime {
  async handleRequest(input: {
    requestId: string;
    userText: string;
    userConfirmed: boolean;
    requestedAddress: string;
  }): Promise&amp;lt;string&amp;gt; {
    const route = await this.routeIntent(input.userText);
    const startedAt = Date.now();

    const result =
      route === &quot;order_support&quot;
        ? await runOrderSupportSkill(input)
        : { status: &quot;success&quot;, responseText: &quot;Fallback response.&quot; as const };

    this.logExecution({
      requestId: input.requestId,
      route,
      latencyMs: Date.now() - startedAt,
      status: result.status
    });

    return result.responseText;
  }

  private async routeIntent(_userText: string): Promise&amp;lt;Route&amp;gt; {
    // Model-assisted route selection.
    return &quot;order_support&quot;;
  }

  private logExecution(event: {
    requestId: string;
    route: Route;
    latencyMs: number;
    status: &quot;success&quot; | &quot;failed&quot;;
  }): void {
    // Structured logs for observability.
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The key learning is that one skill can run an ordered sequence of steps, and each step can decide whether to call zero, one, or many tools. The agent loop still stays simple: route request, run skill, log outcome, and return response.&lt;/p&gt;
&lt;p&gt;The important part is not the syntax. The important part is separation of concerns:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;route selection,&lt;/li&gt;
&lt;li&gt;multi-step skill execution,&lt;/li&gt;
&lt;li&gt;structured execution logging.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Common Pitfalls&lt;/h2&gt;
&lt;h3&gt;Treating every behavior as a tool&lt;/h3&gt;
&lt;p&gt;This creates large, hard-to-manage tool surfaces. Group related behavior into skills instead.&lt;/p&gt;
&lt;h3&gt;Hidden routing rules&lt;/h3&gt;
&lt;p&gt;If routing logic is not explicit, behavior drifts and becomes hard to debug. Keep conflict handling and fallback rules visible.&lt;/p&gt;
&lt;h3&gt;No execution record&lt;/h3&gt;
&lt;p&gt;Without basic logs, misroutes and failures are guesswork. Always emit minimal structured execution events.&lt;/p&gt;
&lt;h2&gt;Lessons Learned&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Part 1 gave the loop, Part 2 gave tool calling, Part 3 gives runtime structure.&lt;/li&gt;
&lt;li&gt;Skills are the right abstraction for multi-step outcomes.&lt;/li&gt;
&lt;li&gt;A small set of guardrails can make agent behavior much easier to trust and operate.&lt;/li&gt;
&lt;li&gt;Safe, observable, controllable execution is a design choice, not an optional add-on.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Series Wrap-Up&lt;/h2&gt;
&lt;p&gt;You now have a complete progression:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Core loop and state management.&lt;/li&gt;
&lt;li&gt;Tool calling and model-driven routing.&lt;/li&gt;
&lt;li&gt;Skill orchestration and execution guardrails.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This is a practical baseline for building agent systems that stay understandable as they grow.&lt;/p&gt;
&lt;p&gt;If you want to continue the series with production operations depth, Part 4 can focus on deployment guardrails, human approval design, incident readiness, and long-term quality evaluation.&lt;/p&gt;
&lt;h2&gt;Official References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://ampcode.com/notes/how-to-build-an-agent&quot;&gt;Amp, How to Build an Agent&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.anthropic.com/research/building-effective-agents&quot;&gt;Anthropic, Building Effective Agents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://platform.claude.com/docs/en/agents-and-tools/tool-use/implement-tool-use&quot;&gt;Anthropic docs, Tool use&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://platform.claude.com/docs/en/agent-sdk/skills&quot;&gt;Anthropic docs, Agent SDK Skills&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://resources.anthropic.com/hubfs/The-Complete-Guide-to-Building-Skill-for-Claude.pdf?hsLang=en&quot;&gt;Anthropic, The Complete Guide to Building Skill for Claude&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/Prat011/awesome-llm-skills&quot;&gt;Community examples, Awesome LLM Skills&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>Cloudflare December 5th 2025 Outage</title><link>https://joshuabellew.com/posts/cloudflare-5-december-2025-outage-explained/</link><guid isPermaLink="true">https://joshuabellew.com/posts/cloudflare-5-december-2025-outage-explained/</guid><description>Deep dive into the Cloudflare outage: how a killswitch exposed a years-old bug in their WAF rules processing</description><pubDate>Fri, 05 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Overview&lt;/h2&gt;
&lt;p&gt;On December 5th, 2025, Cloudflare experienced a 25-minute outage affecting approximately 28% of HTTP traffic.&lt;/p&gt;
&lt;p&gt;TLDR: A combination of a configuation change, plus a killswitch (feature flag), to disable certain code paths from being executed, caused code to try and access object properties that were nil or null values.&lt;/p&gt;
&lt;p&gt;This follows a recent previous outage on 18th Nov 2025 that was also a code exception that was caused by assumptions about the existence of the underlying data / memory being accessed -&amp;gt; https://blog.cloudflare.com/18-november-2025-outage/&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.cloudflare.com/5-december-2025-outage/&quot;&gt;Official post-mortem&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;How Requests Flow Through Cloudflare&apos;s WAF&lt;/h2&gt;
&lt;p&gt;When a request hits Cloudflare, the WAF evaluates it against a set of rules to decide whether to block, allow, or modify it.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;   User&apos;s Browser
        │
        │  GET https://example.com/api/users
        ▼
   ┌─────────────────────────────────────────┐
   │         Cloudflare Edge Server          │
   │                                         │
   │  &quot;Check if this request is malicious    │
   │   before forwarding to origin&quot;          │
   └─────────────────────────────────────────┘
        │
        ▼
   ┌─────────────────────────────────────────┐
   │             WAF Ruleset                 │
   │                                         │
   │  Rule 1: If SQL injection → BLOCK       │
   │  Rule 2: If XSS attack   → BLOCK        │
   │  Rule 3: EXECUTE test-ruleset  ◄────────┼── Special rule
   │  Rule 4: If bot traffic  → CHALLENGE    │
   │                                         │
   └─────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Most rules have simple actions like &lt;code&gt;block&lt;/code&gt; or &lt;code&gt;log&lt;/code&gt;. The &lt;code&gt;execute&lt;/code&gt; action is special. It triggers evaluation of another ruleset. Cloudflare uses this internally to test new rules before releasing them publicly.&lt;/p&gt;
&lt;h2&gt;The Two-Pass Architecture&lt;/h2&gt;
&lt;p&gt;Based on the extremely brief code snippet in the blog post, coupled with the explanation, I&apos;m going to assume cloudflare&apos;s code processes rules in two separate passes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class WAFProcessor {
	async processRequest(request: Request) {
		const ruleset = this.loadRuleset();
		const ruleResults: RuleResult[] = [];
		const subRulesetResults: SubRulesetResult[] =
			[];

		// PASS 1: Evaluate each rule
		for (const rule of ruleset.rules) {
			const result = await this.evaluateRule(
				rule,
				request,
				subRulesetResults
			);
			ruleResults.push(result);
		}

		// PASS 2: Stitch sub-ruleset results back onto rule results
		this.attachResults(
			ruleResults,
			subRulesetResults
		);

		return ruleResults;
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Why two passes? Sub-rulesets might run in parallel, or results might be stored separately for memory efficiency. Either way, the results get attached in a second loop:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Pass 1: Evaluate rules, store results separately
─────────────────────────────────────────────────

   ruleResults[]              subRulesetResults[]
   ┌────────────────────┐     ┌─────────────────┐
   │ Rule 1 (execute)   │────▶│ Sub-results 0   │
   │ results_index: 0   │     ├─────────────────┤
   ├────────────────────┤     │ Sub-results 1   │
   │ Rule 2 (execute)   │────▶│                 │
   │ results_index: 1   │     └─────────────────┘
   ├────────────────────┤
   │ Rule 3 (block)     │
   │ (no sub-results)   │
   └────────────────────┘


Pass 2: Attach results back to rule objects
─────────────────────────────────────────────

   for each ruleResult:
       if action == &quot;execute&quot;:
           ruleResult.execute.results = subRulesetResults[index]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;The Bug&lt;/h2&gt;
&lt;p&gt;The bug lived in how these two passes communicated. Let&apos;s look at the full code:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;interface RuleResult {
	ruleId: string;
	action: &quot;block&quot; | &quot;log&quot; | &quot;execute&quot; | &quot;skip&quot;;
	execute?: {
		results_index: number;
		results?: SubRulesetResult;
	};
}

class WAFProcessor {
	async evaluateRule(
		rule: Rule,
		request: Request,
		subRulesetResults: SubRulesetResult[]
	): Promise&amp;lt;RuleResult&amp;gt; {
		// Check if killswitch is active for this rule
		if (this.isKillswitched(rule.id)) {
			// Skip the action, but still return result metadata
			return {
				ruleId: rule.id,
				action: rule.action, // ← Still says &quot;execute&quot;!
				// execute: ???      // ← Never created!
			};
		}

		// Normal execution for &quot;execute&quot; action
		if (rule.action === &quot;execute&quot;) {
			const subResults = await this.runSubRuleset(
				rule.targetRuleset,
				request
			);
			const index =
				subRulesetResults.push(subResults) - 1;

			return {
				ruleId: rule.id,
				action: &quot;execute&quot;,
				execute: {
					// ← This object gets created
					results_index: index,
				},
			};
		}

		// Handle other actions...
	}

	attachResults(
		ruleResults: RuleResult[],
		subRulesetResults: SubRulesetResult[]
	) {
		for (const result of ruleResults) {
			// THE BUG: Assumes execute object exists if action is &quot;execute&quot;
			if (result.action === &quot;execute&quot;) {
				result.execute.results =
					subRulesetResults[
						result.execute.results_index
					];
				//     ▲
				//     └── 💥 result.execute is undefined when killswitched!
			}
		}
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The two functions had incompatible assumptions:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────────┐
│                                                             │
│  evaluateRule thinks:                                       │
│    &quot;I&apos;ll skip the action but keep the action type           │
│     so logs show what kind of rule it was&quot;                  │
│                                                             │
│  attachResults thinks:                                      │
│    &quot;If action is &apos;execute&apos;, someone definitely ran it       │
│     and created the execute object&quot;                         │
│                                                             │
│  These assumptions worked fine for years...                 │
│  until someone killswitched an &quot;execute&quot; rule.              │
│                                                             │
└─────────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;How the Code Could Be Structured&lt;/h2&gt;
&lt;h3&gt;Option 1: Change the action type when skipped&lt;/h3&gt;
&lt;p&gt;The simplest fix. If you skip a rule, don&apos;t claim it&apos;s still an &quot;execute&quot; action:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async evaluateRule(rule: Rule, request: Request): Promise&amp;lt;RuleResult&amp;gt; {
    if (this.isKillswitched(rule.id)) {
        return {
            ruleId: rule.id,
            action: &quot;skipped&quot;,        // ← Changed from rule.action
            originalAction: rule.action,  // ← Keep for logging
        };
    }
    // ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Option 2: Check for the object before accessing it&lt;/h3&gt;
&lt;p&gt;Defensive programming in the second pass:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;attachResults(ruleResults: RuleResult[], subRulesetResults: SubRulesetResult[]) {
    for (const result of ruleResults) {
        if (result.action === &quot;execute&quot; &amp;amp;&amp;amp; result.execute) {
            //                             ▲
            //                             └── Guard clause
            result.execute.results = subRulesetResults[result.execute.results_index];
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Option 3: Eliminate the second pass entirely&lt;/h3&gt;
&lt;p&gt;Why store an index and look it up later? Just attach the results immediately:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async evaluateRule(rule: Rule, request: Request): Promise&amp;lt;RuleResult&amp;gt; {
    if (this.isKillswitched(rule.id)) {
        return { ruleId: rule.id, action: &quot;skipped&quot; };
    }

    if (rule.action === &quot;execute&quot;) {
        const subResults = await this.runSubRuleset(rule.targetRuleset, request);

        return {
            ruleId: rule.id,
            action: &quot;execute&quot;,
            execute: {
                results: subResults,  // ← Attach immediately, no second pass
            },
        };
    }
    // ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This eliminates the bug entirely because there&apos;s no second pass with mismatched assumptions.&lt;/p&gt;
&lt;h3&gt;Option 4: Use types to make invalid states unrepresentable&lt;/h3&gt;
&lt;p&gt;The real fix. Design your types so the bug can&apos;t exist:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Instead of one type with optional fields...
type RuleResult =
	| { action: &quot;block&quot;; ruleId: string }
	| { action: &quot;log&quot;; ruleId: string }
	| {
			action: &quot;skipped&quot;;
			ruleId: string;
			originalAction: string;
	  }
	| {
			action: &quot;execute&quot;;
			ruleId: string;
			execute: { results: SubRulesetResult };
	  };
//    ▲
//    └── If action is &quot;execute&quot;, execute MUST exist.
//        TypeScript enforces this at compile time.

function attachResults(result: RuleResult) {
	if (result.action === &quot;execute&quot;) {
		// TypeScript KNOWS result.execute exists here
		// No runtime check needed, no bug possible
		console.log(result.execute.results);
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Cloudflare noted that this bug didn&apos;t occur in their Rust-based FL2 proxy. Rust&apos;s type system would force you to handle the &quot;skipped&quot; case explicitly. You can&apos;t just leave the &lt;code&gt;execute&lt;/code&gt; field undefined and hope for the best.&lt;/p&gt;
&lt;h2&gt;Why Increase the Request Buffer Size?&lt;/h2&gt;
&lt;h3&gt;What is a Request Buffer?&lt;/h3&gt;
&lt;p&gt;When you submit a form or upload data to a website, that data travels in the HTTP request body. Before Cloudflare forwards the request to the origin server, the WAF needs to scan it for malicious content like SQL injection or XSS payloads.&lt;/p&gt;
&lt;p&gt;But scanning happens in memory. Cloudflare can&apos;t just stream the body through, it needs to hold it somewhere to analyze it. That &quot;somewhere&quot; is the request buffer.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;┌──────────────────────────────────────────────────────────────────┐
│                        HTTP Request                              │
├──────────────────────────────────────────────────────────────────┤
│  Headers: POST /api/submit                                       │
│           Content-Type: application/json                         │
│           Content-Length: 500000                                 │
├──────────────────────────────────────────────────────────────────┤
│  Body: { &quot;data&quot;: &quot;...500KB of content...&quot; }                      │
│                                                                  │
│        ┌─────────────────────────────────────────────────────┐   │
│        │                  Request Buffer                     │   │
│        │                                                     │   │
│        │  WAF loads body here to scan for:                   │   │
│        │  • SQL injection patterns                           │   │
│        │  • XSS payloads                                     │   │
│        │  • Known exploit signatures                         │   │
│        │  • Malicious file uploads                           │   │
│        │                                                     │   │
│        │  Buffer size: 128KB (old) → 1MB (new)               │   │
│        └─────────────────────────────────────────────────────┘   │
└──────────────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Why 1MB?&lt;/h3&gt;
&lt;p&gt;Cloudflare was responding to &lt;a href=&quot;https://nvd.nist.gov/vuln/detail/CVE-2025-55182&quot;&gt;CVE-2025-55182&lt;/a&gt;, a critical vulnerability in React Server Components. From their post:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;We started rolling out an increase to our buffer size to 1MB, the default limit allowed by Next.js applications. We wanted to make sure as many customers as possible were protected.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The exact behavior for content beyond the 128KB buffer limit isn&apos;t documented. It could be truncated, streamed without scanning, or handled some other way. What we do know is that increasing the buffer to match Next.js&apos;s 1MB default ensures the WAF can analyze the full request body that applications will accept.&lt;/p&gt;
&lt;h3&gt;The Tradeoff&lt;/h3&gt;
&lt;p&gt;Larger buffers mean more memory per request. At Cloudflare&apos;s scale (millions of requests per second), increasing from 128KB to 1MB is significant.&lt;/p&gt;
&lt;h2&gt;Lessons Learned&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Two-pass architectures need clear contracts.&lt;/strong&gt; When different parts of your code make assumptions about data shape, those assumptions need to be explicit and enforced, preferably by types.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The &quot;off&quot; path is rarely tested.&lt;/strong&gt; The killswitch had been used many times, but never on an &quot;execute&quot; rule. The bug hid for years in an untested code path.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Simpler code is safer code.&lt;/strong&gt; The second pass existed for some reason (parallelism? memory?), but it introduced a coupling between two functions that could fall out of sync. Sometimes the simpler design is worth the tradeoff.&lt;/p&gt;
</content:encoded></item><item><title>AWS DynamoDB US-East-1 Outage Explained</title><link>https://joshuabellew.com/posts/aws-us-east-1-outage-explained/</link><guid isPermaLink="true">https://joshuabellew.com/posts/aws-us-east-1-outage-explained/</guid><description>Deep dive into the October 2025 AWS outage: root causes, technical concepts, and lessons learned</description><pubDate>Mon, 27 Oct 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Overview&lt;/h2&gt;
&lt;p&gt;On October 19th / 20th, 2025, AWS&apos;s Northern Virginia (US-East-1) region experienced a cascading outage affecting numerous services, starting with DynamoDB and spreading to EC2, Lambda, NLB, and others. This post breaks down the technical causes and explores the underlying concepts.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://aws.amazon.com/message/101925/&quot;&gt;Official summary&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;Timeline of Events&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;Oct 19, 11:48 PM PDT - DynamoDB DNS race condition triggers failure
    ↓
Oct 20, 2:25 AM PDT - DynamoDB DNS state manually restored
    ↓
Oct 20, 2:25-2:40 AM PDT - Clients recover as DNS caches expire
    ↓
Oct 20, 2:25-5:28 AM PDT - DWFM congestive collapse
                           (Selective restarts at 4:14 AM)
                           (Leases re-established by 5:28 AM)
    ↓
Oct 20, 6:21-10:36 AM PDT - Network Manager propagation delays
                            (New instances launch but lack connectivity)
    ↓
Oct 20, 5:30 AM-2:09 PM PDT - NLB health check failures
                              (Automatic AZ failover disabled at 9:36 AM)
    ↓
Oct 20, 1:50 PM PDT - Full EC2 recovery
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Root Cause: DNS Race Condition in DynamoDB&lt;/h2&gt;
&lt;h3&gt;The Problem&lt;/h3&gt;
&lt;p&gt;A race condition in DynamoDB&apos;s DNS management system resulted in an &lt;strong&gt;incorrect empty DNS record&lt;/strong&gt; for the regional endpoint (&lt;code&gt;dynamodb.us-east-1.amazonaws.com&lt;/code&gt;), making DynamoDB completely unreachable. The automated recovery system failed to repair this invalid state, requiring manual intervention.&lt;/p&gt;
&lt;h3&gt;Architecture Overview&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────────┐
│                    DynamoDB DNS System                      │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌──────────────┐                ┌──────────────┐           │
│  │ DNS Planner  │───────────────▶│ DNS Enactor  │           │
│  │              │  Creates Plans │ (Instance 1) │           │
│  │ Monitors     │                └──────────────┘           │
│  │ Load Bal     │                          │                │
│  │ Health &amp;amp;     │                          │                │
│  │ Capacity     │                ┌──────────────┐           │
│  └──────────────┘                │ DNS Enactor  │           │
│           │                      │ (Instance 2) │           │
│           │                      └──────────────┘           │
│           │                                │                │
│           ▼                                ▼                │
│  ┌──────────────────────────────────────────────────┐       │
│  │   Route 53 (Updates DNS Records for Endpoints)   │       │
│  │         dynamodb.us-east-1.amazonaws.com         │       │
│  └──────────────────────────────────────────────────┘       │
│                                                             │
│  Note: Single regional DNS plan                             │
│        DNS Enactors run redundantly across multiple AZs     │
└─────────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;How the Race Condition Occurred&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; This is a conceptual illustration of the race condition based on the AWS post-mortem. The exact implementation details are AWS-internal.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Time    DNS Enactor 1 (Delayed)         DNS Enactor 2 (Fast Path)
─────────────────────────────────────────────────────────────────────
T0      Working on plan #10             Waiting
        Gets delayed on one endpoint...

T1      Still delayed...                Picks up newer plan #15

T2      Still delayed...                Rapidly processes all endpoints
                                        ✓ Endpoint A
                                        ✓ Endpoint B
                                        ✓ Endpoint C
                                        ✓ ... all complete
                                        Invokes cleanup process
                                        (deletes plans &amp;lt; #12)

T3      Finally completes its delayed   Cleanup: Deletes plan #10
        work from plan #10 based on     (appears to be old/unused)
        stale view of state

T4      - Interleaving causes Route 53 to end up with
           incorrect empty DNS record
        - Automation fails to self-repair
        - DynamoDB unreachable, requires manual intervention
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Code Example: Conceptual Implementation&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; This is simplified pseudocode to illustrate the concept, not actual AWS implementation.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Conceptual DNS Enactor Logic
class DNSEnactor {
	async applyPlan(
		planId: number,
		endpoint: string
	) {
		// Check if this plan is newer than the current one
		const currentPlan = await this.getCurrentPlan(
			endpoint
		);
		if (planId &amp;lt;= currentPlan.version) {
			console.warn(&quot;Plan is older, skipping&quot;);
			return;
		}

		// Apply the plan to Route 53
		// This can take time and might be delayed
		// Problem: No verification that the plan is still valid when we commit
		await this.updateRoute53(endpoint, planId);
	}

	async cleanupOldPlans(latestPlanId: number) {
		// Find and delete plans that are significantly older
		const plans = await this.getAllPlans();
		for (const plan of plans) {
			if (plan.version &amp;lt; latestPlanId - 3) {
				await this.deletePlan(plan.version);
				// Problem: This can delete a plan that&apos;s being applied by a delayed enactor
			}
		}
	}
}

// The Bug: Race Between Apply and Cleanup
// Enactor 1: Reads plan #10, starts applying (gets delayed)
// Enactor 2: Reads plan #15, applies quickly, runs cleanup
// Enactor 2: Deletes plan #10 (seems too old)
// Enactor 1: Finally commits plan #10 based on stale view
// Result: Route 53 ends up with inconsistent/empty record

// The Fix: Compare-and-Set (CAS) semantics
class FixedDNSEnactor {
	async applyPlan(
		planId: number,
		endpoint: string
	) {
		const currentPlan = await this.getCurrentPlan(
			endpoint
		);
		if (planId &amp;lt;= currentPlan.version) {
			return;
		}

		// Use atomic compare-and-set when committing to Route 53
		const success = await this.updateRoute53(
			endpoint,
			planId,
			{
				// Only succeed if version unchanged
				ifCurrentVersionIs: currentPlan.version,
			}
		);

		if (!success) {
			console.warn(
				&quot;Plan version changed during apply, aborting&quot;
			);
		}
	}

	async cleanupOldPlans(latestPlanId: number) {
		const plans = await this.getAllPlans();
		for (const plan of plans) {
			if (plan.version &amp;lt; latestPlanId - 3) {
				// Only delete if not actively being applied
				const isActive =
					await this.checkIfBeingApplied(
						plan.version
					);
				if (!isActive) {
					await this.deletePlan(plan.version);
				}
			}
		}
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Concept Deep Dive: Leases&lt;/h2&gt;
&lt;h3&gt;Technical context&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;To understand what happened, we need to share some information about a few subsystems that are used for the management of EC2 instance launches, as well as for configuring network connectivity for newly launched EC2 instances.&lt;/p&gt;
&lt;p&gt;The first subsystem is DropletWorkflow Manager (DWFM), which is responsible for the management of all the underlying physical servers that are used by EC2 for the hosting of EC2 instances – we call these servers “droplets”.&lt;/p&gt;
&lt;p&gt;The second subsystem is Network Manager, which is responsible for the management and propagation of network state to all EC2 instances and network appliances. Each DWFM manages a set of droplets within each Availability Zone and maintains a lease for each droplet currently under management. This lease allows DWFM to track the droplet state, ensuring that all actions from the EC2 API or within the EC2 instance itself, such as shutdown or reboot operations originating from the EC2 instance operating system, result in the correct state changes within the broader EC2 systems. As part of maintaining this lease, each DWFM host has to check in and complete a state check with each droplet that it manages every few minutes.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;What is a Lease?&lt;/h3&gt;
&lt;p&gt;A &lt;strong&gt;lease&lt;/strong&gt; is a temporary ownership claim or lock on a resource. In AWS EC2&apos;s case, it&apos;s the agreement between DropletWorkflow Manager (DWFM) and each physical server (&quot;droplet&quot;) that tracks server state and availability.&lt;/p&gt;
&lt;h3&gt;Visual Representation&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;┌──────────────────────────────────────────────────────────┐
│         DropletWorkflow Manager (DWFM) Fleet             │
│      [Multiple DWFM hosts per AZ, each managing          │
│       a set of droplets within that AZ]                  │
└─────────────────┬────────────────────────────────────────┘
                  │ Lease Check (every few minutes)
                  │ &quot;Are you still alive? Ready for work?&quot;
                  │ State checks depend on DynamoDB
                  │
                  ▼
┌─────────────────────────────────────────────────────────┐
│              Physical Servers (Droplets)                │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐ │
│  │ Droplet 1│  │ Droplet 2│  │ Droplet 3│  │ Droplet N│ │
│  │  Lease:  │  │  Lease:  │  │  Lease:  │  │  Lease:  │ │
│  │  Active  │  │  Active  │  │ Expired  │  │  Active  │ │
│  │          │  │          │  │          │  │          │ │
│  │ Status:  │  │ Status:  │  │ Unusable │  │ Status:  │ │
│  │ Ready    │  │ In-Use   │  │ for new  │  │ Ready    │ │
│  │          │  │ (2 VMs)  │  │ launches │  │          │ │
│  └──────────┘  └──────────┘  └──────────┘  └──────────┘ │
└─────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;How Leases Work&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;// Conceptual Lease Mechanism
class DropletWorkflowManager {
	private leases = new Map&amp;lt;string, Lease&amp;gt;(); // droplet_id → lease

	// Periodic heartbeat to renew leases
	async renewLease(
		dropletId: string
	): Promise&amp;lt;void&amp;gt; {
		try {
			// Check if droplet is healthy
			const health =
				await this.checkDropletHealth(dropletId);

			// DWFM&apos;s state checks depend on DynamoDB
			// During the outage, this call failed due to DNS issues
			await this.completeStateCheckWithDynamoDB(
				dropletId,
				{
					status: health ? &quot;active&quot; : &quot;degraded&quot;,
					lastCheckIn: Date.now(),
					expiryTime: Date.now() + LEASE_TIMEOUT, // e.g., 5 minutes
				}
			);

			this.leases.set(dropletId, {
				status: &quot;active&quot;,
				expiryTime: Date.now() + LEASE_TIMEOUT,
			});
		} catch (error) {
			console.error(
				`Failed to renew lease for ${dropletId}`
			);
			// Lease will expire if not renewed in time
		}
	}

	// Check if droplet is available for new EC2 launches
	isAvailableForLaunch(
		dropletId: string
	): boolean {
		const lease = this.leases.get(dropletId);
		if (!lease) return false;

		// Lease must be active and not expired
		return (
			lease.status === &quot;active&quot; &amp;amp;&amp;amp;
			lease.expiryTime &amp;gt; Date.now()
		);
	}

	// Handle expired leases
	onLeaseExpired(dropletId: string): void {
		console.warn(
			`Lease expired for ${dropletId}`
		);
		// Mark droplet as unavailable for new instance launches
		this.markUnavailable(dropletId);
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Why Leases Matter&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;State Coordination&lt;/strong&gt;: Ensures DWFM knows which servers are available&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Failure Detection&lt;/strong&gt;: Expired leases indicate unhealthy or unreachable servers&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Resource Management&lt;/strong&gt;: Only droplets with active leases can host new EC2 instances&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;The Outage Scenario&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;Normal Operation:
┌──────────────────────────────────────────────────────┐
│ DWFM → Renew Lease → DynamoDB → Success              │
│ (every few minutes)                                  │
│ Droplets available for launches                      │
└──────────────────────────────────────────────────────┘

During Outage:
┌──────────────────────────────────────────────────────┐
│ DWFM → Renew Lease → DynamoDB → DNS Failure          │
│ Cannot update lease state                            │
│ Lease expires → Droplets marked unavailable          │
│ &quot;Insufficient capacity&quot; errors for new launches      │
└──────────────────────────────────────────────────────┘

After DynamoDB Recovery (Thundering Herd):
┌──────────────────────────────────────────────────────┐
│ DWFM attempts to renew 100,000+ leases simultaneously│
│ Queue overload → Processing delays                   │
│ Renewal attempts timeout before completing           │
│ Leases re-expire → More work queued                  │
│ Retries pile up faster than completions              │
│ → Congestive collapse                                │
│ Solution: Selective DWFM restarts + throttling       │
│           Cleared queues and restored progress       │
└──────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Cascading Failures&lt;/h2&gt;
&lt;h3&gt;Service Dependency Chain&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;DynamoDB Root Cause and Chain of Failures
┌────────────────────────────────────────────────────────────┐
│                   Layer 0: Root Cause                      │
│  ┌──────────────────────────────────────────────────┐      │
│  │         DynamoDB DNS Race Condition              │      │
│  │    Incorrect empty DNS record published          │      │
│  │    dynamodb.us-east-1.amazonaws.com unreachable  │      │
│  └──────────────┬───────────────────────────────────┘      │
└─────────────────┼──────────────────────────────────────────┘
                  │
                  ▼
┌────────────────────────────────────────────────────────────┐
│               Layer 1: Direct Dependencies                 │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐         │
│  │    EC2      │  │   Lambda    │  │   Redshift  │         │
│  │ DWFM leases │  │ Function ops│  │  Query ops  │         │
│  │   expire    │  │   blocked   │  │   blocked   │         │
│  └──────┬──────┘  └──────┬──────┘  └──────┬──────┘         │
└─────────┼────────────────┼────────────────┼────────────────┘
          │                │                │
          ▼                ▼                ▼
┌────────────────────────────────────────────────────────────┐
│         Layer 2: Secondary Effects (Thundering Herd)       │
│  ┌──────────────────────────────────────────────────┐      │
│  │ EC2 DWFM: Massive backlog of lease renewals      │      │
│  │ → Congestive collapse                            │      │
│  │ → Blocks new instance launches                   │      │
│  │ → Network Manager backlog (6:21-10:36 AM)        │      │
│  │    New instances launch but lack connectivity    │      │
│  └──────────────┬───────────────────────────────────┘      │
└─────────────────┼──────────────────────────────────────────┘
                  │
                  ▼
┌────────────────────────────────────────────────────────────┐
│               Layer 3: Dependent Services                  │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐         │
│  │     NLB     │  │    ECS      │  │   Lambda    │         │
│  │ Health check│  │ Container   │  │  Execution  │         │
│  │   failures  │  │   launches  │  │  throttled  │         │
│  │  (network   │  │   blocked   │  │  (priority  │         │
│  │   delays)   │  │             │  │   to sync)  │         │
│  │ Mitigated:  │  │             │  │             │         │
│  │  Disabled   │  │             │  │             │         │
│  │  auto AZ    │  │             │  │             │         │
│  │  failover   │  │             │  │             │         │
│  │  @ 9:36 AM  │  │             │  │             │         │
│  └─────────────┘  └─────────────┘  └─────────────┘         │
└────────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Key Concepts Explained&lt;/h2&gt;
&lt;h3&gt;1. Thundering Herd Problem&lt;/h3&gt;
&lt;p&gt;When a resource becomes available after being unavailable, all waiting processes try to access it simultaneously, overwhelming the system.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Example: What happened in DWFM after DynamoDB recovered
class ThunderingHerdExample {
	async demonstrate() {
		// Simulate expired leases during DynamoDB outage
		const expiredLeases = []; // 100,000+ droplets
		for (let i = 0; i &amp;lt; 100000; i++) {
			expiredLeases.push({
				id: `droplet-${i}`,
				leaseExpired: true,
			});
		}

		// DynamoDB comes back online
		const dynamoDBRecovered = true;

		if (dynamoDBRecovered) {
			// BAD: All requests at once
			await Promise.all(
				expiredLeases.map((lease) =&amp;gt;
					this.renewLease(lease.id)
				)
			);
			// Result: System overload, timeouts, more queued work

			// GOOD: Rate-limited approach with jitter and backoff
			const BATCH_SIZE = 100;
			const MAX_CONCURRENT = 1000; // Global concurrency limit
			let retryDelay = 100; // Start with 100ms

			for (
				let i = 0;
				i &amp;lt; expiredLeases.length;
				i += BATCH_SIZE
			) {
				const batch = expiredLeases.slice(
					i,
					i + BATCH_SIZE
				);

				// Add jitter to prevent synchronized retries
				const jitter = Math.random() * 50; // 0-50ms random delay

				await Promise.all(
					batch.map((lease) =&amp;gt;
						this.renewLease(lease.id)
					)
				);

				// Queue-size-based rate limiting (AWS&apos;s planned fix)
				const queueSize =
					await this.getQueueSize();
				if (queueSize &amp;gt; 10000) {
					// Exponential backoff when queue is large
					retryDelay = Math.min(
						retryDelay * 1.5,
						5000
					); // Cap at 5s
				} else {
					retryDelay = 100; // Reset when queue drains
				}

				await this.sleep(retryDelay + jitter);
			}
		}
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. Congestive Collapse&lt;/h3&gt;
&lt;p&gt;When a system tries to process more work than it can handle, processing delays increase, leading to more work being queued, which further exacerbates the problem.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What happened with DWFM:&lt;/strong&gt; Retries piled up because processing took so long that in-flight leases re-expired before completion, creating more work than could be drained.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Normal: [Request] → [Process] → [Done] (5ms)
        [Request] → [Process] → [Done] (5ms)

Congested:
[Request spiral] → [Queue growing 1000s] → [Processing at max capacity]
    ↑                                          ↓
    └────── [More requests queued ────────┘ (faster than processing)
             (Leases re-expire during       (Creating even more work)
              processing delays)

Result: Queue grows infinitely, response times explode
Solution: Restart DWFM hosts to clear queue + throttle incoming work
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. DNS Management Systems&lt;/h3&gt;
&lt;p&gt;Large distributed systems use DNS for service discovery and load balancing. Managing thousands of load balancer IPs requires sophisticated automation.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Traditional Service:
┌─────────┐
│ Client  │──────▶ [dynamodb.us-east-1.amazonaws.com]
└─────────┘       ┌──────────────────────────────────┐
                  │ Route 53 DNS Resolver            │
                  │ Returns single IP: 52.94.1.1     │
                  └──────────────────────────────────┘

AWS&apos;s Approach (Hundreds of IPs):
┌─────────┐
│ Client  │──────▶ [dynamodb.us-east-1.amazonaws.com]
└─────────┘       ┌──────────────────────────────────┐
                  │ Route 53 DNS Resolver            │
                  │ Returns 50+ IPs for load sharing │
                  │ 52.94.1.1, 52.94.1.2, 52.94.1.3  │
                  │ 52.94.1.4, 52.94.1.5, ...        │
                  │    ... 52.94.1.50                │
                  └──────────────────────────────────┘
                         ↓
                  ┌──────────────────────────────────┐
                  │ Load Balancer Pool               │
                  │ (Hundreds of NLBs across AZs)    │
                  └──────────────────────────────────┘

Note: In practice, Route 53 answers and LB pools are more complex
      (weighted, health-checked, versioned). Numbers simplified for clarity.
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;AWS&apos;s Planned Fixes&lt;/h2&gt;
&lt;h3&gt;1. DynamoDB DNS System&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;// Before (Race condition bug)
class DNSEnactor {
	async cleanupOldPlans(latestPlanId: number) {
		// Deletes any plan significantly older
		// Doesn&apos;t check if plan is currently being applied
		const plans = await this.getAllPlans();
		for (const plan of plans) {
			if (plan.version &amp;lt; latestPlanId - 3) {
				await this.deletePlan(plan.version);
			}
		}
	}
}

// After (Partial Fix - still has a subtle race condition!)
class FixedDNSEnactor {
	async cleanupOldPlans(latestPlanId: number) {
		const plans = await this.getAllPlans();
		for (const plan of plans) {
			if (plan.version &amp;lt; latestPlanId - 3) {
				// Check if not actively being applied
				const isActive =
					await this.checkIfBeingApplied(
						plan.version
					);
				// RACE CONDITION: Between this check and the delete below,
				// another enactor could start applying this plan!
				// This is a TOCTOU (Time-of-Check to Time-of-Use) bug
				if (!isActive) {
					await this.deletePlan(plan.version);
				}
			}
		}
	}
}

// Better Fix: Atomic check-and-delete operation
class BetterDNSEnactor {
	async cleanupOldPlans(latestPlanId: number) {
		const plans = await this.getAllPlans();
		for (const plan of plans) {
			if (plan.version &amp;lt; latestPlanId - 3) {
				// Atomically delete only if not in use
				// This combines the check and delete into a single atomic operation
				const deleted =
					await this.deleteIfNotInUse(
						plan.version
					);
				if (deleted) {
					console.log(
						`Cleaned up plan ${plan.version}`
					);
				}
			}
		}
	}

	// Atomic operation: check and delete in one transaction
	async deleteIfNotInUse(
		planVersion: number
	): Promise&amp;lt;boolean&amp;gt; {
		// This would be implemented as a single database transaction or
		// using conditional delete with DynamoDB&apos;s ConditionExpression
		// Example: DELETE WHERE version = X AND inUse = false
		return await this.db.conditionalDelete({
			version: planVersion,
			condition: { inUse: false },
		});
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; Even the &quot;partial fix&quot; above has a subtle &lt;a href=&quot;https://en.wikipedia.org/wiki/Time-of-check_to_time-of-use&quot;&gt;TOCTOU (Time-of-Check to Time-of-Use)&lt;/a&gt; race condition between checking if a plan is active and deleting it. In a multi-threaded environment with multiple DNS Enactors, another enactor could start applying a plan in the tiny window between the check and the delete.&lt;/p&gt;
&lt;p&gt;The better approach is to use &lt;strong&gt;atomic operations&lt;/strong&gt; that combine the check and delete into a single transaction. In AWS&apos;s case, this might use DynamoDB&apos;s conditional writes, distributed locks (like with Redis or ZooKeeper), or database transactions that guarantee the check and delete happen atomically.&lt;/p&gt;
&lt;p&gt;This is a great example of how fixing one race condition can introduce another if you&apos;re not careful. Distributed systems are hard!&lt;/p&gt;
&lt;h3&gt;2. NLB Velocity Controls&lt;/h3&gt;
&lt;p&gt;Limit how quickly health check failures can remove capacity to prevent overreaction to temporary issues.&lt;/p&gt;
&lt;h3&gt;3. EC2 Improvements&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Enhanced testing for DWFM recovery workflows&lt;/li&gt;
&lt;li&gt;Better throttling mechanisms for data propagation queues&lt;/li&gt;
&lt;li&gt;Queue-size based rate limiting&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Lessons Learned&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;Note: All examples are using Typescript / Node.js for learning purposes only. Do not use this code in production without understanding atomic operations.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Most of the above race conditions, thundering herd and other cascading issues
mentioned are very much only ever visible in high scale systems, and even more so
in AWS&apos;s case.&lt;/p&gt;
&lt;p&gt;I&apos;ve only ever once seen the thundering herd problem in production when I was on-call at &lt;a href=&quot;https://www.dazn.com&quot;&gt;DAZN&lt;/a&gt;. Maybe thats a blog post for another day!&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What have we learned?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Race conditions.&lt;/strong&gt; You can build the most sophisticated system in the world, but if two pieces of code can step on each other&apos;s toes at the wrong time, it can cause unexpected and unknown issues. The DynamoDB DNS issue was a subtle timing bug that only appeared under specific conditions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The thundering herd problem.&lt;/strong&gt; When DynamoDB came back online, DWFM tried to renew 100,000+ leases all at once. The system collapsed under its own recovery attempts. Jitter, backoff, queue-size based throttling, are common techniques to counter this.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Not everything can be automated (can it?).&lt;/strong&gt; The automation failed to fix the DNS issue. Engineers had to step in, disable systems, restart services, and manually restore things.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cascading failures.&lt;/strong&gt; DynamoDB broke, which broke EC2 leases, which triggered a thundering herd, which overloaded Network Manager, which broke NLB health checks. Each failure created the conditions for the next one.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Complexity.&lt;/strong&gt; AWS have a large scale and complex DNS management system with planners and enactors running across multiple availability zones for resilience. The race condition created an invalid state that the system couldn&apos;t recover from.&lt;/p&gt;
</content:encoded></item><item><title>Build a Basic AI Agent in TypeScript, Part 2: Tool Calling and LLM Intent Routing</title><link>https://joshuabellew.com/posts/agent-series-part-2-custom-tool-routing-typescript/</link><guid isPermaLink="true">https://joshuabellew.com/posts/agent-series-part-2-custom-tool-routing-typescript/</guid><description>Add a weather tool, pass tool definitions to the model, and let the LLM decide when to call tools versus answer directly</description><pubDate>Mon, 29 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Overview&lt;/h2&gt;
&lt;p&gt;Part 1 introduced the core agent loop. Part 2 adds tool calling. A weather tool is registered with the model, then the model decides whether to call that tool based on user intent. This is a common production pattern in major agent stacks: tool definitions are passed in context, and the model routes per turn.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TLDR:&lt;/strong&gt; A tool performs deterministic external actions, while the LLM decides when to invoke the tool. The runtime executes tool calls, returns results, and continues the loop.&lt;/p&gt;
&lt;h2&gt;Full Series&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;/posts/agent-series-part-1-basic-loop-typescript&quot;&gt;Part 1, The Core Loop&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Part 2, Tool Calling and LLM Intent Routing (you are here)&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/posts/agent-series-part-3-multi-step-orchestration-typescript&quot;&gt;Part 3, Multi-Step Skill Orchestration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/posts/agent-series-part-4-production-readiness&quot;&gt;Part 4, Production Readiness and Operational Guardrails&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Sections&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#what-changes-from-part-1&quot;&gt;What Changes from Part 1&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#core-concepts-and-keywords&quot;&gt;Core Concepts and Keywords&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#architecture&quot;&gt;Architecture&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#tool-definition&quot;&gt;Tool Definition&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#why-llm-intent-routing&quot;&gt;Why LLM Intent Routing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#context-budget-and-tool-surface-area&quot;&gt;Context Budget and Tool Surface Area&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#runnable-example&quot;&gt;Runnable Example&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#real-conversation-example&quot;&gt;Real Conversation Example&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#why-this-matters-in-production&quot;&gt;Why This Matters in Production&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#common-pitfalls&quot;&gt;Common Pitfalls&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#lessons-learned&quot;&gt;Lessons Learned&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#what-comes-next-in-part-3&quot;&gt;What Comes Next in Part 3&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#official-references&quot;&gt;Official References&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;What Changes from Part 1&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Add a weather tool implementation backed by Open-Meteo.&lt;/li&gt;
&lt;li&gt;Pass tool definitions to the model on each inference call.&lt;/li&gt;
&lt;li&gt;Let the model route by choosing between natural-language answer and tool calls.&lt;/li&gt;
&lt;li&gt;Add a tool execution loop that sends tool results back to the model.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Core Concepts and Keywords&lt;/h2&gt;
&lt;h3&gt;Tool&lt;/h3&gt;
&lt;p&gt;A &lt;strong&gt;tool&lt;/strong&gt; is a deterministic capability adapter, for example, &lt;code&gt;get_weather(location)&lt;/code&gt;. It executes external work and returns structured data.&lt;/p&gt;
&lt;h3&gt;Tool Calling&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Tool calling&lt;/strong&gt; is the request and response handshake where the model emits tool calls, the runtime executes them, then returns tool results back to the model.&lt;/p&gt;
&lt;h3&gt;Intent Routing&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Intent routing&lt;/strong&gt; is the decision of which path to use for a request. In this post, routing is model-driven. The model decides if the weather tool should be called.&lt;/p&gt;
&lt;h3&gt;Tool Call Loop&lt;/h3&gt;
&lt;p&gt;A &lt;strong&gt;tool call loop&lt;/strong&gt; repeats until there are no more tool calls in the model response:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Model response includes tool calls.&lt;/li&gt;
&lt;li&gt;Runtime executes tool calls.&lt;/li&gt;
&lt;li&gt;Runtime appends tool results to conversation.&lt;/li&gt;
&lt;li&gt;Model is called again with updated conversation.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Architecture&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;┌──────────────────────────────────────────────────────────────┐
│                Agent Loop with LLM Tool Routing              │
├──────────────────────────────────────────────────────────────┤
│ User message                                                 │
│   │                                                          │
│   ▼                                                          │
│ Append to conversation[]                                     │
│   │                                                          │
│   ▼                                                          │
│ Call model with messages + tools[]                           │
│   │                                                          │
│   ├── no tool_calls ──▶ return assistant text                │
│   │                                                          │
│   └── tool_calls ──▶ execute tool(s)                         │
│                        │                                     │
│                        ▼                                     │
│                  append tool results                         │
│                        │                                     │
│                        ▼                                     │
│            call model again with updated history             │
└──────────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Tool Definition&lt;/h2&gt;
&lt;p&gt;The model needs a clear tool schema and description:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const tools: ToolDefinition[] = [
  {
    type: &quot;function&quot;,
    function: {
      name: &quot;get_weather&quot;,
      description:
        &quot;Get current weather for a location. Use this when the user asks about current weather, temperature, forecast, rain, wind, or humidity.&quot;,
      parameters: {
        type: &quot;object&quot;,
        properties: {
          location: {
            type: &quot;string&quot;,
            description: &quot;City or place name, for example Tokyo or London, UK&quot;
          }
        },
        required: [&quot;location&quot;],
        additionalProperties: false
      }
    }
  }
];
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Why LLM Intent Routing&lt;/h2&gt;
&lt;p&gt;This implementation intentionally uses model-driven routing, not code-based intent matching.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Better handling for varied natural language.&lt;/li&gt;
&lt;li&gt;Better compatibility with mainstream tool-calling patterns.&lt;/li&gt;
&lt;li&gt;Cleaner separation: model chooses tools, runtime executes tools.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The runtime still controls execution limits, error handling, and unknown tool behavior.&lt;/p&gt;
&lt;h2&gt;Context Budget and Tool Surface Area&lt;/h2&gt;
&lt;p&gt;Tool names, descriptions, and schemas are usually included in prompt context, so tool count and tool verbosity affect token usage.&lt;/p&gt;
&lt;p&gt;Practical implications:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Keep tool descriptions precise and high-signal.&lt;/li&gt;
&lt;li&gt;Scope tools to agent domain.&lt;/li&gt;
&lt;li&gt;Avoid passing unrelated tools.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A weather-focused agent should not include unrelated operational tools like API key rotation tools. That increases cost and misrouting risk.&lt;/p&gt;
&lt;h2&gt;Runnable Example&lt;/h2&gt;
&lt;p&gt;Working file:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;src/basic-weather-tool-routing-openrouter.ts&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Run it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;bun src/basic-weather-tool-routing-openrouter.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Create &lt;code&gt;.env&lt;/code&gt; first. Bun loads this automatically at runtime, see &lt;a href=&quot;https://bun.com/docs/runtime/environment-variables&quot;&gt;Bun environment variables&lt;/a&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;OPENROUTER_API_KEY=your_api_key_here
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Full Runnable File&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;import { createInterface } from &quot;node:readline/promises&quot;;
import { stdin as input, stdout as output } from &quot;node:process&quot;;

type Role = &quot;system&quot; | &quot;user&quot; | &quot;assistant&quot; | &quot;tool&quot;;

interface ToolCall {
  id: string;
  name: string;
  argumentsJson: string;
}

interface Message {
  role: Role;
  content: string;
  toolCallId?: string;
  toolCalls?: Array&amp;lt;{
    id: string;
    type: &quot;function&quot;;
    function: {
      name: string;
      arguments: string;
    };
  }&amp;gt;;
}

interface ToolDefinition {
  type: &quot;function&quot;;
  function: {
    name: string;
    description: string;
    parameters: {
      type: &quot;object&quot;;
      properties: Record&amp;lt;string, unknown&amp;gt;;
      required?: string[];
      additionalProperties?: boolean;
    };
  };
}

interface ModelClient {
  createMessage(input: {
    model: string;
    messages: Message[];
    tools: ToolDefinition[];
    maxTokens: number;
  }): Promise&amp;lt;{ text: string; toolCalls: ToolCall[] }&amp;gt;;
}

interface AgentConfig {
  model: string;
  maxTokens: number;
  maxTurns: number;
  maxToolRoundsPerTurn: number;
}

interface WeatherSnapshot {
  location: string;
  tempC: string;
  description: string;
  humidity: string;
  windKmph: string;
}

interface WeatherTool {
  getCurrentWeather(location: string): Promise&amp;lt;WeatherSnapshot | null&amp;gt;;
}

class OpenRouterModelClient implements ModelClient {
  constructor(private readonly apiKey: string) {}

  async createMessage(input: {
    model: string;
    messages: Message[];
    tools: ToolDefinition[];
    maxTokens: number;
  }): Promise&amp;lt;{ text: string; toolCalls: ToolCall[] }&amp;gt; {
    const response = await fetch(&quot;https://openrouter.ai/api/v1/chat/completions&quot;, {
      method: &quot;POST&quot;,
      headers: {
        Authorization: `Bearer ${this.apiKey}`,
        &quot;Content-Type&quot;: &quot;application/json&quot;
      },
      body: JSON.stringify({
        model: input.model,
        messages: input.messages.map((m) =&amp;gt; {
          if (m.role === &quot;assistant&quot; &amp;amp;&amp;amp; m.toolCalls) {
            return {
              role: &quot;assistant&quot;,
              content: m.content,
              tool_calls: m.toolCalls
            };
          }

          if (m.role === &quot;tool&quot;) {
            return {
              role: &quot;tool&quot;,
              tool_call_id: m.toolCallId,
              content: m.content
            };
          }

          return {
            role: m.role,
            content: m.content
          };
        }),
        tools: input.tools,
        tool_choice: &quot;auto&quot;,
        max_tokens: input.maxTokens
      })
    });

    if (!response.ok) {
      const errorBody = await response.text();
      throw new Error(`OpenRouter error ${response.status}: ${errorBody}`);
    }

    const data = (await response.json()) as {
      choices?: Array&amp;lt;{
        message?: {
          content?: string | null;
          tool_calls?: Array&amp;lt;{
            id: string;
            type: &quot;function&quot;;
            function: {
              name: string;
              arguments: string;
            };
          }&amp;gt;;
        };
      }&amp;gt;;
    };

    const message = data.choices?.[0]?.message;
    const text = (message?.content || &quot;&quot;).trim();
    const toolCalls =
      message?.tool_calls?.map((call) =&amp;gt; ({
        id: call.id,
        name: call.function.name,
        argumentsJson: call.function.arguments
      })) || [];

    return { text, toolCalls };
  }
}

class OpenMeteoWeatherTool implements WeatherTool {
  async getCurrentWeather(location: string): Promise&amp;lt;WeatherSnapshot | null&amp;gt; {
    const geoUrl = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(location)}&amp;amp;count=1`;
    const geoResponse = await fetch(geoUrl, { signal: AbortSignal.timeout(8000) });
    if (!geoResponse.ok) return null;

    const geo = (await geoResponse.json()) as {
      results?: Array&amp;lt;{
        name?: string;
        country?: string;
        latitude?: number;
        longitude?: number;
      }&amp;gt;;
    };

    const place = geo.results?.[0];
    if (!place?.latitude || !place?.longitude) return null;

    const weatherUrl =
      `https://api.open-meteo.com/v1/forecast?latitude=${place.latitude}` +
      `&amp;amp;longitude=${place.longitude}` +
      &quot;&amp;amp;current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&quot;;

    const weatherResponse = await fetch(weatherUrl, { signal: AbortSignal.timeout(8000) });
    if (!weatherResponse.ok) return null;

    const weatherData = (await weatherResponse.json()) as {
      current?: {
        temperature_2m?: number;
        relative_humidity_2m?: number;
        weather_code?: number;
        wind_speed_10m?: number;
      };
    };

    const current = weatherData.current;
    if (typeof current?.temperature_2m !== &quot;number&quot;) return null;

    return {
      location: `${place.name || location}${place.country ? `, ${place.country}` : &quot;&quot;}`,
      tempC: String(current.temperature_2m),
      description: this.describeWeatherCode(current.weather_code),
      humidity:
        typeof current.relative_humidity_2m === &quot;number&quot;
          ? String(current.relative_humidity_2m)
          : &quot;unknown&quot;,
      windKmph:
        typeof current.wind_speed_10m === &quot;number&quot;
          ? String(current.wind_speed_10m)
          : &quot;unknown&quot;
    };
  }

  private describeWeatherCode(code?: number): string {
    const labels: Record&amp;lt;number, string&amp;gt; = {
      0: &quot;Clear sky&quot;,
      1: &quot;Mainly clear&quot;,
      2: &quot;Partly cloudy&quot;,
      3: &quot;Overcast&quot;,
      45: &quot;Fog&quot;,
      48: &quot;Depositing rime fog&quot;,
      51: &quot;Light drizzle&quot;,
      53: &quot;Moderate drizzle&quot;,
      55: &quot;Dense drizzle&quot;,
      61: &quot;Slight rain&quot;,
      63: &quot;Moderate rain&quot;,
      65: &quot;Heavy rain&quot;,
      71: &quot;Slight snow&quot;,
      73: &quot;Moderate snow&quot;,
      75: &quot;Heavy snow&quot;,
      80: &quot;Rain showers&quot;,
      95: &quot;Thunderstorm&quot;
    };

    if (typeof code !== &quot;number&quot;) return &quot;Unknown conditions&quot;;
    return labels[code] || `Weather code ${code}`;
  }
}

class Agent {
  private conversation: Message[] = [];
  private readonly tools: ToolDefinition[];

  constructor(
    private readonly modelClient: ModelClient,
    private readonly config: AgentConfig,
    private readonly weatherTool: WeatherTool,
    private readonly getUserInput: () =&amp;gt; Promise&amp;lt;string | null&amp;gt;,
    private readonly render: (text: string) =&amp;gt; void
  ) {
    this.tools = [
      {
        type: &quot;function&quot;,
        function: {
          name: &quot;get_weather&quot;,
          description:
            &quot;Get current weather for a location. Use this when the user asks about current weather, temperature, forecast, rain, wind, or humidity.&quot;,
          parameters: {
            type: &quot;object&quot;,
            properties: {
              location: {
                type: &quot;string&quot;,
                description: &quot;City or place name, for example Tokyo or London, UK&quot;
              }
            },
            required: [&quot;location&quot;],
            additionalProperties: false
          }
        }
      }
    ];
  }

  async run(): Promise&amp;lt;void&amp;gt; {
    let turnCount = 0;
    this.render(&quot;Chat started. Submit empty input to exit.&quot;);

    while (turnCount &amp;lt; this.config.maxTurns) {
      const userInput = await this.getUserInput();
      if (!userInput || userInput.trim() === &quot;&quot;) {
        this.render(&quot;Session ended by input.&quot;);
        break;
      }

      this.conversation.push({ role: &quot;user&quot;, content: userInput });

      try {
        const reply = await this.runTurnWithTools();
        this.conversation.push({ role: &quot;assistant&quot;, content: reply });
        this.render(reply);
      } catch (error) {
        const message = error instanceof Error ? error.message : &quot;Unknown runtime error&quot;;
        this.render(`Request failed: ${message}`);
      }

      turnCount += 1;
    }

    if (turnCount &amp;gt;= this.config.maxTurns) {
      this.render(&quot;Session ended at max turn limit.&quot;);
    }
  }

  private async runTurnWithTools(): Promise&amp;lt;string&amp;gt; {
    for (let round = 0; round &amp;lt; this.config.maxToolRoundsPerTurn; round += 1) {
      const response = await this.modelClient.createMessage({
        model: this.config.model,
        maxTokens: this.config.maxTokens,
        messages: this.conversation,
        tools: this.tools
      });

      if (response.toolCalls.length === 0) {
        return response.text || &quot;[No text response]&quot;;
      }

      this.conversation.push({
        role: &quot;assistant&quot;,
        content: response.text || &quot;&quot;,
        toolCalls: response.toolCalls.map((call) =&amp;gt; ({
          id: call.id,
          type: &quot;function&quot;,
          function: {
            name: call.name,
            arguments: call.argumentsJson
          }
        }))
      });

      for (const toolCall of response.toolCalls) {
        const result = await this.executeTool(toolCall);
        this.conversation.push({
          role: &quot;tool&quot;,
          toolCallId: toolCall.id,
          content: result
        });
      }
    }

    return &quot;The agent reached max tool rounds for this turn.&quot;;
  }

  private async executeTool(toolCall: ToolCall): Promise&amp;lt;string&amp;gt; {
    if (toolCall.name !== &quot;get_weather&quot;) {
      return JSON.stringify({ error: `Unknown tool: ${toolCall.name}` });
    }

    let parsedArgs: { location?: string } = {};
    try {
      parsedArgs = JSON.parse(toolCall.argumentsJson) as { location?: string };
    } catch {
      return JSON.stringify({ error: &quot;Invalid JSON arguments for get_weather&quot; });
    }

    const location = parsedArgs.location?.trim();
    if (!location) {
      return JSON.stringify({ error: &quot;Missing location argument for get_weather&quot; });
    }

    const weather = await this.weatherTool.getCurrentWeather(location);
    if (!weather) {
      return JSON.stringify({ error: `Weather lookup failed for ${location}` });
    }

    return JSON.stringify(weather);
  }
}

async function main(): Promise&amp;lt;void&amp;gt; {
  const apiKey = process.env.OPENROUTER_API_KEY;
  if (!apiKey) {
    throw new Error(&quot;Missing OPENROUTER_API_KEY in environment&quot;);
  }

  const modelClient = new OpenRouterModelClient(apiKey);
  const weatherTool: WeatherTool = new OpenMeteoWeatherTool();

  const readline = createInterface({ input, output });
  const getUserInput = async (): Promise&amp;lt;string | null&amp;gt; =&amp;gt; {
    try {
      return await readline.question(&quot;You: &quot;);
    } catch {
      return null;
    }
  };

  const render = (text: string): void =&amp;gt; {
    console.log(`Assistant: ${text}`);
  };

  const agent = new Agent(
    modelClient,
    {
      model: &quot;openai/gpt-4o-mini&quot;,
      maxTokens: 1024,
      maxTurns: 20,
      maxToolRoundsPerTurn: 4
    },
    weatherTool,
    getUserInput,
    render
  );

  try {
    await agent.run();
  } finally {
    readline.close();
  }
}

main().catch((error) =&amp;gt; {
  const message = error instanceof Error ? error.message : &quot;Unknown startup error&quot;;
  console.error(`Fatal error: ${message}`);
  process.exit(1);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Real Conversation Example&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;Assistant: Chat started. Submit empty input to exit.
You: what is the weather in tokyo
Assistant: The current weather in Tokyo is as follows:
- Temperature: 4.9°C
- Description: Overcast
- Humidity: 81%
- Wind Speed: 9.8 km/h
You: what is the current stock price of nvidia
Assistant: I currently don&apos;t have access to real-time stock prices. You can check the latest stock price of NVIDIA on financial news websites, stock market apps, or your brokerage platform.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The weather request triggers &lt;code&gt;get_weather&lt;/code&gt;. The stock request does not match available tools, so the model answers directly.&lt;/p&gt;
&lt;h2&gt;Why This Matters in Production&lt;/h2&gt;
&lt;h3&gt;1. Natural-Language Flexibility&lt;/h3&gt;
&lt;p&gt;Model-driven routing handles varied user phrasing better than brittle keyword matching.&lt;/p&gt;
&lt;h3&gt;2. Standard Tool-Calling Pattern&lt;/h3&gt;
&lt;p&gt;This approach matches modern agent SDK workflows: pass tools to the model, run the tool loop, and return tool results.&lt;/p&gt;
&lt;h3&gt;3. Better Extensibility&lt;/h3&gt;
&lt;p&gt;Adding new capabilities becomes a tool-definition problem instead of accumulating route-condition code.&lt;/p&gt;
&lt;h3&gt;4. Clear Runtime Control Points&lt;/h3&gt;
&lt;p&gt;Even with model-driven routing, runtime controls remain explicit: max tool rounds, tool execution validation, and unknown-tool handling.&lt;/p&gt;
&lt;h2&gt;Common Pitfalls&lt;/h2&gt;
&lt;h3&gt;Overloading Tool Surface&lt;/h3&gt;
&lt;p&gt;Too many unrelated tools in a single agent increases token cost and tool-selection ambiguity.&lt;/p&gt;
&lt;h3&gt;Weak Tool Descriptions&lt;/h3&gt;
&lt;p&gt;Sparse descriptions reduce routing quality. Tool descriptions should include when to use and when not to use.&lt;/p&gt;
&lt;h3&gt;Missing Tool Loop Limits&lt;/h3&gt;
&lt;p&gt;Without max tool rounds per turn, badly formed loops can become expensive and unstable.&lt;/p&gt;
&lt;h3&gt;Unvalidated Tool Arguments&lt;/h3&gt;
&lt;p&gt;Tool arguments can be malformed. Always parse and validate input before external calls.&lt;/p&gt;
&lt;h2&gt;Lessons Learned&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Tool calling is the bridge from conversational agents to real-world actions.&lt;/li&gt;
&lt;li&gt;LLM intent routing is practical when paired with strict runtime controls.&lt;/li&gt;
&lt;li&gt;Tool quality depends heavily on schema and description quality.&lt;/li&gt;
&lt;li&gt;Scoped tool sets improve both cost and reliability.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;What Comes Next in Part 3&lt;/h2&gt;
&lt;p&gt;Part 3 adds multi-tool and multi-skill orchestration, then introduces stronger controls for observability, idempotency, human approval gates, and production runbook readiness.&lt;/p&gt;
&lt;h2&gt;Official References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.anthropic.com/research/building-effective-agents&quot;&gt;Anthropic, Building Effective Agents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://platform.claude.com/docs/en/agents-and-tools/tool-use/implement-tool-use&quot;&gt;Anthropic docs, How to implement tool use&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://python.langchain.com/docs/how_to/routing/&quot;&gt;LangChain docs, Routing&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>Build a Basic AI Agent in TypeScript, Part 1: The Core Loop</title><link>https://joshuabellew.com/posts/agent-series-part-1-basic-loop-typescript/</link><guid isPermaLink="true">https://joshuabellew.com/posts/agent-series-part-1-basic-loop-typescript/</guid><description>A comprehensive guide to the minimal agent architecture, conversation state, and deterministic control loop in TypeScript</description><pubDate>Fri, 14 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Overview&lt;/h2&gt;
&lt;p&gt;At its core, an agent is a control loop wrapped around model calls and state management. This post introduces that loop first, then shows a runnable TypeScript implementation that matches the same flow. The aim is to keep the mental model simple while still giving readers something practical to execute.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TLDR:&lt;/strong&gt; A basic agent is mostly a deterministic loop that sends conversation history to a model, appends responses, and repeats until an exit condition is met.&lt;/p&gt;
&lt;h2&gt;Full Series&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Part 1, The Core Loop (you are here)&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/posts/agent-series-part-2-custom-tool-routing-typescript&quot;&gt;Part 2, Tool Calling and LLM Intent Routing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/posts/agent-series-part-3-multi-step-orchestration-typescript&quot;&gt;Part 3, Multi-Step Skill Orchestration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/posts/agent-series-part-4-production-readiness&quot;&gt;Part 4, Production Readiness and Operational Guardrails&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Sections&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#what-we-are-building-in-part-1&quot;&gt;What We Are Building in Part 1&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#core-concepts-and-keywords&quot;&gt;Core Concepts and Keywords&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#architecture-at-a-glance&quot;&gt;Architecture at a Glance&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#turn-lifecycle&quot;&gt;Turn Lifecycle&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#clarifying-two-terms&quot;&gt;Clarifying Two Terms&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#typescript-implementation&quot;&gt;TypeScript Implementation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#why-this-design-matters&quot;&gt;Why This Design Matters&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#common-pitfalls-in-basic-agent-loops&quot;&gt;Common Pitfalls in Basic Agent Loops&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#lessons-learned&quot;&gt;Lessons Learned&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#what-comes-next-in-part-2&quot;&gt;What Comes Next in Part 2&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#official-references&quot;&gt;Official References&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;What We Are Building in Part 1&lt;/h2&gt;
&lt;p&gt;In this part, we are intentionally building the smallest useful baseline:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A terminal-style chat loop.&lt;/li&gt;
&lt;li&gt;Persistent conversation memory across turns.&lt;/li&gt;
&lt;li&gt;A model call wrapper.&lt;/li&gt;
&lt;li&gt;Basic error handling and exit conditions.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We are &lt;strong&gt;not&lt;/strong&gt; adding tools or skills yet. That is intentional. If the loop is weak, everything built on top will be fragile.&lt;/p&gt;
&lt;h2&gt;Core Concepts and Keywords&lt;/h2&gt;
&lt;p&gt;Before code, here are the key terms used throughout the series.&lt;/p&gt;
&lt;h3&gt;Agent&lt;/h3&gt;
&lt;p&gt;An &lt;strong&gt;agent&lt;/strong&gt; is a program that repeatedly observes input, decides what to do next, and produces output. In practical LLM apps, the &quot;decide&quot; step often means making a model call and interpreting the response.&lt;/p&gt;
&lt;h3&gt;Turn&lt;/h3&gt;
&lt;p&gt;A &lt;strong&gt;turn&lt;/strong&gt; is one cycle of interaction:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;User message enters the system.&lt;/li&gt;
&lt;li&gt;Model generates a response.&lt;/li&gt;
&lt;li&gt;Response is shown and stored.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;Conversation State&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Conversation state&lt;/strong&gt; is the full message history sent back to the model on each turn. Most model APIs are stateless by request, so memory lives in the application, not on the model server.&lt;/p&gt;
&lt;h3&gt;Inference&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Inference&lt;/strong&gt; is one model generation call, for example, &quot;Given these messages, produce the next assistant message.&quot;&lt;/p&gt;
&lt;h3&gt;Deterministic Control Loop&lt;/h3&gt;
&lt;p&gt;A &lt;strong&gt;deterministic control loop&lt;/strong&gt; means runtime flow is explicit and predictable. The model generates text, but the program decides:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;when to call the model,&lt;/li&gt;
&lt;li&gt;what context to send,&lt;/li&gt;
&lt;li&gt;when to stop,&lt;/li&gt;
&lt;li&gt;how to handle failures.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Architecture at a Glance&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;┌────────────────────────────────────────────────────────┐
│                 Basic Agent Runtime                    │
├────────────────────────────────────────────────────────┤
│ while (turnCount &amp;lt; maxTurns)                           │
│   │                                                    │
│   ▼                                                    │
│ Read user input                                        │
│   │                                                    │
│   ├── empty input ──▶ Exit                             │
│   │                                                    │
│   ▼                                                    │
│ Append user message to conversation[]                  │
│   │                                                    │
│   ▼                                                    │
│ Model call (inference) with full conversation[]        │
│   │                                                    │
│   ▼                                                    │
│ Append assistant message and display response          │
│   │                                                    │
│   ▼                                                    │
│ Next loop iteration                                    │
└────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This diagram shows system structure and control flow. It answers &quot;what components exist and how data moves through the while loop&quot;.&lt;/p&gt;
&lt;h2&gt;Turn Lifecycle&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;Single iteration (Turn N) inside the while loop
    ↓
Read user input
    ↓
Append user message to conversation state
    ↓
Call model with full conversation
    ↓
Append assistant response to conversation state
    ↓
Render response
    ↓
Loop condition checked again
    ↓
Turn N+1 begins if condition still holds
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This diagram is intentionally narrower. It zooms into one turn only and ignores broader component boundaries.&lt;/p&gt;
&lt;h2&gt;Clarifying Two Terms&lt;/h2&gt;
&lt;h3&gt;Model Call (Inference)&lt;/h3&gt;
&lt;p&gt;&quot;Model call&quot; and &quot;inference&quot; refer to the same step. The runtime sends current conversation history to the model API and receives the assistant&apos;s next message.&lt;/p&gt;
&lt;h3&gt;Append + Display&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Append the assistant response to &lt;code&gt;conversation[]&lt;/code&gt; so memory is preserved.&lt;/li&gt;
&lt;li&gt;Display the response in the terminal, for example with &lt;code&gt;console.log&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;TypeScript Implementation&lt;/h2&gt;
&lt;p&gt;This section uses a hybrid approach:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Start with a short conceptual loop.&lt;/li&gt;
&lt;li&gt;Follow with a runnable implementation.&lt;/li&gt;
&lt;li&gt;Call out runtime details that are useful, but secondary to the core concept.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;Conceptual Loop (Read First)&lt;/h3&gt;
&lt;p&gt;This snippet is intentionally small and focused on loop semantics.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; This is simplified pseudocode to illustrate the concept, not actual implementation.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class Agent {
	private conversation: Message[] = [];

	async run(): Promise&amp;lt;void&amp;gt; {
		let turnCount = 0;

		while (turnCount &amp;lt; this.config.maxTurns) {
			const input = await this.getUserInput();
			if (!input || input.trim() === &quot;&quot;) break;

			this.conversation.push({
				role: &quot;user&quot;,
				content: input,
				timestamp: Date.now(),
			});

			const assistantText =
				await this.runInference(
					this.conversation,
				);

			this.conversation.push({
				role: &quot;assistant&quot;,
				content: assistantText,
				timestamp: Date.now(),
			});

			this.render(assistantText);
			turnCount += 1;
		}
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Runnable Example (Copy and Run)&lt;/h3&gt;
&lt;h4&gt;Local Setup&lt;/h4&gt;
&lt;p&gt;The following commands create a minimal runnable TypeScript project.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mkdir basic-agent-loop
cd basic-agent-loop
bun init -y
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Create &lt;code&gt;.env&lt;/code&gt; and add the API key. Bun automatically loads &lt;code&gt;.env&lt;/code&gt; files at runtime, see &lt;a href=&quot;https://bun.com/docs/runtime/environment-variables&quot;&gt;Bun environment variables&lt;/a&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;OPENROUTER_API_KEY=your_api_key_here
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;&lt;code&gt;src/main.ts&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;This is a minimal working loop that can be copied and run.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { createInterface } from &quot;node:readline/promises&quot;;
import {
	stdin as input,
	stdout as output,
} from &quot;node:process&quot;;

type Role = &quot;user&quot; | &quot;assistant&quot;;

interface Message {
	role: Role;
	content: string;
	timestamp: number;
}

interface ModelClient {
	createMessage(input: {
		model: string;
		messages: Array&amp;lt;{
			role: Role;
			content: string;
		}&amp;gt;;
		maxTokens: number;
	}): Promise&amp;lt;{ text: string }&amp;gt;;
}

interface AgentConfig {
	model: string;
	maxTokens: number;
	maxTurns: number;
}

class OpenRouterModelClient
	implements ModelClient
{
	constructor(
		private readonly apiKey: string,
	) {}

	async createMessage(input: {
		model: string;
		messages: Array&amp;lt;{
			role: Role;
			content: string;
		}&amp;gt;;
		maxTokens: number;
		}): Promise&amp;lt;{ text: string }&amp;gt; {
		const response = await fetch(
			&quot;https://openrouter.ai/api/v1/chat/completions&quot;,
			{
				method: &quot;POST&quot;,
				headers: {
					Authorization: `Bearer ${this.apiKey}`,
					&quot;Content-Type&quot;: &quot;application/json&quot;,
				},
				body: JSON.stringify({
					model: input.model,
					messages: input.messages,
					max_tokens: input.maxTokens,
				}),
			},
		);

		if (!response.ok) {
			const errorBody = await response.text();
			throw new Error(
				`OpenRouter error ${response.status}: ${errorBody}`,
			);
		}

		const data = (await response.json()) as {
			choices?: Array&amp;lt;{
				message?: {
					content?:
						| string
						| Array&amp;lt;{
								type?: string;
								text?: string;
							}&amp;gt;;
				};
			}&amp;gt;;
		};

		const content = data.choices?.[0]?.message?.content;
		const text =
			typeof content === &quot;string&quot;
				? content.trim()
				: Array.isArray(content)
					? content
							.map((item) =&amp;gt;
								item.type === &quot;text&quot;
									? item.text || &quot;&quot;
									: &quot;&quot;,
							)
							.join(&quot;\n&quot;)
							.trim()
					: &quot;&quot;;

		return { text: text || &quot;[No text response]&quot; };
	}
}

class Agent {
	private conversation: Message[] = [];

	constructor(
		private readonly modelClient: ModelClient,
		private readonly config: AgentConfig,
		private readonly getUserInput: () =&amp;gt; Promise&amp;lt;
			string | null
		&amp;gt;,
		private readonly render: (
			text: string,
		) =&amp;gt; void,
	) {}

	async run(): Promise&amp;lt;void&amp;gt; {
		let turnCount = 0;

		this.render(
			&quot;Chat started. Submit empty input to exit.&quot;,
		);

		while (turnCount &amp;lt; this.config.maxTurns) {
			const userInput = await this.getUserInput();

			if (!userInput || userInput.trim() === &quot;&quot;) {
				this.render(&quot;Session ended by input.&quot;);
				break;
			}

			this.appendUserMessage(userInput);

			try {
				const assistantText =
					await this.runInference();
				this.appendAssistantMessage(
					assistantText,
				);
				this.render(assistantText);
			} catch (error) {
				const message =
					error instanceof Error
						? error.message
						: &quot;Unknown inference error&quot;;
				this.render(
					`Inference failed: ${message}`,
				);
			}

			turnCount += 1;
		}

		if (turnCount &amp;gt;= this.config.maxTurns) {
			this.render(
				&quot;Session ended at max turn limit.&quot;,
			);
		}
	}

	private appendUserMessage(
		content: string,
	): void {
		this.conversation.push({
			role: &quot;user&quot;,
			content,
			timestamp: Date.now(),
		});
	}

	private appendAssistantMessage(
		content: string,
	): void {
		this.conversation.push({
			role: &quot;assistant&quot;,
			content,
			timestamp: Date.now(),
		});
	}

	private async runInference(): Promise&amp;lt;string&amp;gt; {
		const response =
			await this.modelClient.createMessage({
				model: this.config.model,
				maxTokens: this.config.maxTokens,
				messages: this.conversation.map(
					(message) =&amp;gt; ({
						role: message.role,
						content: message.content,
					}),
				),
			});

		return response.text;
	}
}

async function main(): Promise&amp;lt;void&amp;gt; {
	const apiKey = process.env.OPENROUTER_API_KEY;
	if (!apiKey) {
		throw new Error(
			&quot;Missing OPENROUTER_API_KEY in environment&quot;,
		);
	}

	const modelClient = new OpenRouterModelClient(
		apiKey,
	);

	const readline = createInterface({
		input,
		output,
	});
	const getUserInput = async (): Promise&amp;lt;
		string | null
	&amp;gt; =&amp;gt; {
		try {
			return await readline.question(&quot;You: &quot;);
		} catch {
			return null;
		}
	};

	const render = (text: string): void =&amp;gt; {
		console.log(`Assistant: ${text}`);
	};

	const agent = new Agent(
		modelClient,
		{
			model: &quot;openai/gpt-4o-mini&quot;,
			maxTokens: 1024,
			maxTurns: 20,
		},
		getUserInput,
		render,
	);

	try {
		await agent.run();
	} finally {
		readline.close();
	}
}

main().catch((error) =&amp;gt; {
	const message =
		error instanceof Error
			? error.message
			: &quot;Unknown startup error&quot;;
	console.error(`Fatal error: ${message}`);
	process.exit(1);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;bun src/main.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Example conversation from a real run:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Assistant: Chat started. Submit empty input to exit.
You: hello world
Assistant: Hello! How can I assist you today?
You: what is the weather in london
Assistant: I don&apos;t have real-time data access to provide current weather information. However, you can easily check the weather in London by using a weather website, app, or a voice-activated assistant. If you&apos;re looking for typical weather conditions in London or historical data, I can help with that!
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This result is expected in Part 1. The loop is working, but there are no tools yet, so the model cannot fetch live weather. Part 2 adds a custom tool so the agent can call external systems for real-time data.&lt;/p&gt;
&lt;h3&gt;What to Ignore for Now&lt;/h3&gt;
&lt;p&gt;To stay focused on the core idea, treat these as implementation details in Part 1:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;API client wiring (&lt;code&gt;fetch&lt;/code&gt; setup and headers).&lt;/li&gt;
&lt;li&gt;Environment loading from &lt;code&gt;.env&lt;/code&gt; (handled by Bun runtime).&lt;/li&gt;
&lt;li&gt;Terminal wiring (&lt;code&gt;readline&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The only required mental model in this part is still the same:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Read input.&lt;/li&gt;
&lt;li&gt;Append to conversation state.&lt;/li&gt;
&lt;li&gt;Call model with full state.&lt;/li&gt;
&lt;li&gt;Append assistant response.&lt;/li&gt;
&lt;li&gt;Display response.&lt;/li&gt;
&lt;li&gt;Repeat until exit condition.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Why This Design Matters&lt;/h2&gt;
&lt;h3&gt;1. Testability&lt;/h3&gt;
&lt;p&gt;Injected input, output, and model dependencies make &lt;code&gt;Agent.run()&lt;/code&gt; easy to test with fakes, which means regressions in loop behavior are caught before deployment. In practice, this lowers the risk of breaking basic interaction flow when adding tools, retries, or routing logic later. &lt;a href=&quot;https://mswjs.io/&quot;&gt;Mock Service Worker&lt;/a&gt; is a practical choice for mocking LLM API responses.&lt;/p&gt;
&lt;h3&gt;2. Replaceable Model Providers&lt;/h3&gt;
&lt;p&gt;Provider portability protects the architecture from API churn, pricing changes, and model quality shifts. With &lt;code&gt;ModelClient&lt;/code&gt; as an interface, the core loop remains stable while provider-specific code stays isolated in one adapter. In production systems, this isolation reduces migration cost and makes multi-provider fallback strategies realistic. It also makes it easier to switch providers or models based on use case.&lt;/p&gt;
&lt;h3&gt;3. Explicit Failure Handling&lt;/h3&gt;
&lt;p&gt;Explicit per-turn error handling improves runtime resilience. A single failed model call can be surfaced to the user without killing the process, which preserves session continuity and helps operators debug failure patterns. This becomes especially important when rate limits, transient network issues, and provider timeouts appear under load.&lt;/p&gt;
&lt;h3&gt;4. Operational Guardrails&lt;/h3&gt;
&lt;p&gt;A max-turn cap and clear exit paths are basic but important guardrails. They prevent unbounded loops, runaway token spend, and confusing terminal behavior during incidents. Production hardening starts with these deterministic limits, then expands to rate limits, retries with backoff, and structured logging.&lt;/p&gt;
&lt;h2&gt;Common Pitfalls in Basic Agent Loops&lt;/h2&gt;
&lt;h3&gt;Pitfall 1: Forgetting Full Context&lt;/h3&gt;
&lt;p&gt;If only the latest user message is sent each time, the assistant appears to forget prior turns. Beyond poor UX, this also breaks task continuity and can cause inconsistent outputs in multi-step workflows. Stateful conversation replay is what makes the system behave like a coherent session instead of unrelated single prompts.&lt;/p&gt;
&lt;h3&gt;Pitfall 2: Mixing Business Logic and I/O&lt;/h3&gt;
&lt;p&gt;When stdin, rendering, and model calls are tightly coupled, testing and debugging become painful. Tight coupling also blocks reuse, for example, reusing the same loop in CLI, API, and background worker contexts. Separating concerns early keeps future integrations low-risk.&lt;/p&gt;
&lt;h3&gt;Pitfall 3: No Exit Strategy&lt;/h3&gt;
&lt;p&gt;Without turn limits and stop conditions, loops can run indefinitely. In production this translates directly into cost leaks and hard-to-diagnose runtime behavior. Deterministic stop rules are required for operational predictability.&lt;/p&gt;
&lt;h3&gt;Pitfall 4: Silent Failure Paths&lt;/h3&gt;
&lt;p&gt;If model errors are swallowed, the interface appears unresponsive with no clear reason. Silent failures are expensive during incident response because there is no signal for users or operators. Even minimal loops should return visible errors and leave room for structured logs later.&lt;/p&gt;
&lt;h2&gt;Lessons Learned&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;The core loop is the foundation, behavior comes from control flow and state before tools are introduced.&lt;/li&gt;
&lt;li&gt;Stateless model APIs require explicit conversation persistence in application code.&lt;/li&gt;
&lt;li&gt;Clean boundaries between loop logic, model adapter, and I/O make future hardening much easier.&lt;/li&gt;
&lt;li&gt;Production readiness starts with simple controls, explicit errors, deterministic exits, and replaceable integrations.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;What Comes Next in Part 2&lt;/h2&gt;
&lt;p&gt;In Part 2, the implementation adds a custom tool and the request and response handshake that lets the model ask the runtime to perform real actions. That is the step where the agent starts affecting the outside world.&lt;/p&gt;
&lt;p&gt;Keeping this baseline code clean makes the next layers, tools and skills, easier to reason about and maintain.&lt;/p&gt;
&lt;h2&gt;Official References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.anthropic.com/research/building-effective-agents&quot;&gt;Anthropic, Building Effective Agents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://platform.claude.com/docs/en/agents-and-tools/tool-use/implement-tool-use&quot;&gt;Anthropic docs, How to implement tool use&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>Why Elixir and the BEAM Might Be the Best Runtime for AI Agents</title><link>https://joshuabellew.com/posts/why-elixir-and-the-beam-might-be-the-best-runtime-for-ai-agents/</link><guid isPermaLink="true">https://joshuabellew.com/posts/why-elixir-and-the-beam-might-be-the-best-runtime-for-ai-agents/</guid><description>A look at Jido, an Elixir agent framework, and what TypeScript developers can learn from the BEAM&apos;s approach to concurrency, fault tolerance, and agent architecture</description><pubDate>Sat, 28 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Overview&lt;/h2&gt;
&lt;p&gt;Most agent frameworks today are built in TypeScript or Python. They are mature, well-supported, and familiar. But there is a growing argument that the BEAM VM, the runtime behind Erlang and Elixir, is a better foundation for running agent systems at scale.&lt;/p&gt;
&lt;p&gt;I came across &lt;a href=&quot;https://jido.run&quot;&gt;Jido&lt;/a&gt;, an open-source Elixir agent framework that shipped version 2.0 in March 2026. It makes some strong claims about why the BEAM is the right runtime for agents. Some of those claims are worth understanding, even if you have no plans to leave TypeScript.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TLDR:&lt;/strong&gt; Jido is an Elixir agent framework built on the BEAM VM. The BEAM gives agents multi-core concurrency, per-process fault isolation, and supervision out of the box. Jido adds a command-pattern architecture that separates agent decisions from side effects. That last idea is the most useful takeaway for TypeScript developers.&lt;/p&gt;
&lt;h2&gt;Sections&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;#what-is-jido&quot;&gt;What Is Jido&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#why-these-patterns-matter-for-agent-systems&quot;&gt;Why These Patterns Matter for Agent Systems&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#the-beam-runtime-why-jido-chose-elixir&quot;&gt;The BEAM Runtime: Why Jido Chose Elixir&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#so-why-use-jido&quot;&gt;So Why Use Jido?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#official-references&quot;&gt;Official References&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;What Is Jido&lt;/h2&gt;
&lt;p&gt;Jido is an Elixir framework for orchestrating agents, built on tried and tested patterns and designed to take advantage of the BEAM VM. It shipped version 2.0 in March 2026. Here are the core ideas from their &lt;a href=&quot;https://jido.run/blog/jido-2-0-release&quot;&gt;release post&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;Agents as Data&lt;/h3&gt;
&lt;p&gt;A Jido agent is a struct with state, actions, and tools. There is no hidden mutation or implicit state management. The agent is just data.&lt;/p&gt;
&lt;h3&gt;The Command Pattern&lt;/h3&gt;
&lt;p&gt;All agent logic flows through a single function, &lt;code&gt;cmd/2&lt;/code&gt;. You pass in an agent and an action. You get back an updated agent and a list of &lt;strong&gt;directives&lt;/strong&gt;, typed data structures that describe side effects. The agent never executes side effects directly. It describes what should happen, and a separate runtime layer executes it.&lt;/p&gt;
&lt;p&gt;This is the &amp;lt;a href=&quot;#&quot; class=&quot;glossary-link&quot; data-glossary=&quot;command-pattern&quot;&amp;gt;command pattern&amp;lt;/a&amp;gt;. Decisions are pure. Execution is separate.&lt;/p&gt;
&lt;h3&gt;Pluggable Strategies&lt;/h3&gt;
&lt;p&gt;How an agent processes actions is controlled by a strategy module. Jido ships with Direct (sequential execution) and &amp;lt;a href=&quot;#&quot; class=&quot;glossary-link&quot; data-glossary=&quot;fsm&quot;&amp;gt;FSM&amp;lt;/a&amp;gt; (state machines with transition guards). Jido AI adds LLM-based strategies like &amp;lt;a href=&quot;#&quot; class=&quot;glossary-link&quot; data-glossary=&quot;react&quot;&amp;gt;ReAct&amp;lt;/a&amp;gt;, &amp;lt;a href=&quot;#&quot; class=&quot;glossary-link&quot; data-glossary=&quot;cot&quot;&amp;gt;Chain-of-Thought&amp;lt;/a&amp;gt;, and &amp;lt;a href=&quot;#&quot; class=&quot;glossary-link&quot; data-glossary=&quot;tot&quot;&amp;gt;Tree-of-Thoughts&amp;lt;/a&amp;gt;.&lt;/p&gt;
&lt;p&gt;The key point is that the strategy is swappable. The agent&apos;s logic, tools, and state stay the same. Only the execution pattern changes.&lt;/p&gt;
&lt;h3&gt;Schema-Validated Actions as LLM Tools&lt;/h3&gt;
&lt;p&gt;Actions are defined once with a typed schema. They work both as executable code and as tool definitions the LLM can call. There is no separate tool definition to maintain.&lt;/p&gt;
&lt;h2&gt;Why These Patterns Matter for Agent Systems&lt;/h2&gt;
&lt;p&gt;The most useful ideas from Jido are not Elixir-specific. They are architectural patterns that can improve any agent system. Here is what they look like in TypeScript and why you should care.&lt;/p&gt;
&lt;h3&gt;Separate Decisions from Side Effects&lt;/h3&gt;
&lt;p&gt;This is the biggest takeaway. In a typical TypeScript agent, the tool call happens inside the agent loop. The decision and the execution are interleaved.&lt;/p&gt;
&lt;p&gt;Jido&apos;s approach is different. The agent&apos;s decision function is pure. It takes state and an action, returns new state and a list of commands. A separate layer executes the commands.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; This is simplified pseudocode to illustrate the concept, not actual implementation.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;interface Directive {
  type: string;
  payload: Record&amp;lt;string, unknown&amp;gt;;
}

interface CommandResult {
  state: AgentState;
  directives: Directive[];
}

// Pure function. No network calls, no database writes, no side effects.
function decide(state: AgentState, action: Action): CommandResult {
  const newState = { ...state, orderCount: state.orderCount + 1 };
  const directives: Directive[] = [
    { type: &quot;send_email&quot;, payload: { to: &quot;customer@example.com&quot;, subject: &quot;Order confirmed&quot; } },
    { type: &quot;update_database&quot;, payload: { table: &quot;orders&quot;, id: &quot;123&quot;, status: &quot;confirmed&quot; } },
  ];

  return { state: newState, directives };
}

// Separate runtime executes directives after the decision is made.
async function execute(directives: Directive[]): Promise&amp;lt;void&amp;gt; {
  for (const directive of directives) {
    await handlers[directive.type](directive.payload);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This separation buys you several things.&lt;/p&gt;
&lt;h3&gt;Testability Without Mocks&lt;/h3&gt;
&lt;p&gt;When agent decisions are pure functions, testing does not require mocking HTTP clients, databases, or LLM providers. You pass in state and an action. You assert on the returned state and directives. No setup, no teardown, no flaky network dependencies.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Test the decision, not the execution
test(&quot;processes order and increments count&quot;, () =&amp;gt; {
  const state: AgentState = { orderCount: 0 };
  const result = decide(state, { type: &quot;process_order&quot;, orderId: &quot;123&quot; });

  expect(result.state.orderCount).toBe(1);
  expect(result.directives).toContainEqual({
    type: &quot;send_email&quot;,
    payload: expect.objectContaining({ to: &quot;customer@example.com&quot; }),
  });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Compare this to testing an agent where the tool call triggers a real HTTP request inside the loop. You need to mock the dependency, which couples your test to the tool&apos;s implementation rather than the agent&apos;s decision.&lt;/p&gt;
&lt;h3&gt;Observability for Free&lt;/h3&gt;
&lt;p&gt;When side effects are described as data before execution, you get observability properties without extra work:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Log every decision before execution.&lt;/strong&gt; Every directive is a data structure. Log it, inspect it, or require approval before it runs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Replay agent decisions.&lt;/strong&gt; Feed the same state and action into the decision function. Get the same directives back. Deterministic replay without hitting external systems.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dry-run mode.&lt;/strong&gt; Run the decision logic, collect the directives, skip execution. Useful for testing new strategies against production-like input.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Audit trails.&lt;/strong&gt; Every directive is a record of what the agent intended to do. Combined with execution results, you get a complete decision history.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;Interleaved (typical TypeScript agent)
┌─────────────────────────────────────────────────┐
│ Agent Loop                                      │
│   Think ──▶ Call Tool ──▶ Get Result ──▶ Think  │
│              ▲                                  │
│              Side effect happens here.          │
│              Decision and execution are coupled.│
└─────────────────────────────────────────────────┘

Separated (command pattern)
┌──────────────────────┐    ┌─────────────────────┐
│ Decision Layer       │    │ Execution Layer     │
│                      │    │                     │
│ decide(state, action)│───▶│ Execute directives  │
│   Returns:           │    │   Log each one      │
│   new state          │    │   Approve if needed │
│   directives (data)  │    │   Then execute      │
│                      │    │                     │
│ Pure. Testable.      │    │ Observable. Audited.│
└──────────────────────┘    └─────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This connects directly to the observability requirements from &lt;a href=&quot;/posts/agent-series-part-4-production-readiness&quot;&gt;Part 4&lt;/a&gt; of the agent series. The same primitives, decision logging, replay, audit trails, become easier to implement when the architecture enforces separation.&lt;/p&gt;
&lt;h2&gt;The BEAM Runtime: Why Jido Chose Elixir&lt;/h2&gt;
&lt;p&gt;The patterns above work in any language. But Jido chose Elixir for a reason. The BEAM VM, the runtime behind Erlang and Elixir, was built by Ericsson in the 1980s for telecom switching systems. It has properties that are hard to replicate in Node.js.&lt;/p&gt;
&lt;h3&gt;Where Node.js Is Strong&lt;/h3&gt;
&lt;p&gt;Node.js handles I/O-bound concurrent work well. When an agent calls an LLM API or queries a database, &lt;code&gt;await&lt;/code&gt; yields control back to the event loop. Other agents continue running. A single Node process can manage thousands of concurrent agents all waiting on network responses.&lt;/p&gt;
&lt;p&gt;For most agent workloads today, where the majority of time is spent waiting on LLM API responses, Node&apos;s concurrency model works fine. TypeScript also has the largest ecosystem of AI/LLM libraries, the widest provider support, and a far larger developer community than Elixir.&lt;/p&gt;
&lt;h3&gt;Where the BEAM Has a Structural Advantage&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Multi-core utilization.&lt;/strong&gt; Node.js runs JavaScript on a single core by default. Using additional cores requires &lt;code&gt;worker_threads&lt;/code&gt; or the &lt;code&gt;cluster&lt;/code&gt; module, which add complexity around state sharing and lifecycle management. The BEAM runs a scheduler per CPU core and distributes processes across all of them automatically. No application code changes needed.&lt;/p&gt;
&lt;p&gt;This matters when agents do non-trivial local computation: parsing large LLM responses, running complex decision trees, serializing state. These are CPU-bound operations that block the Node event loop but get time-sliced across cores on the BEAM.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fault isolation.&lt;/strong&gt; In Node, all agents share a single process. An unhandled error, a memory leak in one agent&apos;s closure, or an out-of-memory condition affects every agent in that process. On the BEAM, each agent runs in its own lightweight process with its own heap and garbage collector. One agent crashing does not affect others.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Node.js
┌────────────────────────────────────────────────┐
│              Single Node Process               │
│                                                │
│  Agent A    Agent B    Agent C    Agent D      │
│  (promise)  (promise)  (promise)  (promise)    │
│                                                │
│  Shared event loop, shared memory, shared fate │
│  One OOM or unhandled error kills all agents   │
└────────────────────────────────────────────────┘

BEAM
┌────────────────────────────────────────────────┐
│              BEAM VM (all cores)               │
│                                                │
│  ┌─────────┐ ┌─────────┐ ┌─────────┐           │
│  │Agent A  │ │Agent B  │ │Agent C  │  ...      │
│  │Own heap │ │Own heap │ │Own heap │           │
│  │Own GC   │ │Own GC   │ │Own GC   │           │
│  └─────────┘ └─────────┘ └─────────┘           │
│                                                │
│  Agent B crashes. Supervisor restarts it.      │
│  Agents A and C are unaffected.                │
└────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Supervision trees.&lt;/strong&gt; Processes are organized into hierarchies where parent processes monitor children and restart them on failure. In Node, you would need something like PM2 or custom retry logic. On the BEAM, this is built into the runtime.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Built-in distribution.&lt;/strong&gt; The BEAM natively supports connecting VMs across machines. Processes on different nodes communicate directly without an external message broker. In TypeScript, scaling agents across machines typically requires a distributed data store (Redis, DynamoDB), a message queue, or a messaging protocol like gRPC.&lt;/p&gt;
&lt;h3&gt;An Important Caveat&lt;/h3&gt;
&lt;p&gt;Whether these advantages matter in practice depends on the workload. If your agents spend 95% of their time waiting on LLM API responses, the multi-core and CPU scheduling benefits are largely theoretical. Without instrumenting a real agent system and measuring where time is actually spent, the runtime comparison is an architectural argument, not an empirical one.&lt;/p&gt;
&lt;h2&gt;So Why Use Jido?&lt;/h2&gt;
&lt;p&gt;If you already know Elixir and are comfortable with its concurrency primitives, Jido looks like a well-designed framework. The architecture is clean, the separation of concerns is strong, and the BEAM gives you runtime properties that would take significant effort to replicate in Node.js. For teams already invested in the Elixir ecosystem, it is a compelling choice for building agent systems.&lt;/p&gt;
&lt;p&gt;If you are coming from TypeScript, the calculus is different. Node.js handles I/O-bound agent workloads well, the ecosystem of AI libraries is far larger, and the developer community around TypeScript agents is more active. Switching runtimes is a big commitment, and for most agent systems today, the bottleneck is not the runtime. It is the LLM API latency, the quality of your prompts, and the reliability of your orchestration logic.&lt;/p&gt;
&lt;p&gt;The reasons to seriously consider Jido and the BEAM come down to scale and resilience. If you are building a system with hundreds of concurrent agents, need hard fault isolation between them, want automatic multi-core utilization without infrastructure complexity, or require agents that stay up for months without restarts, those are problems the BEAM was designed to solve.&lt;/p&gt;
&lt;p&gt;For everyone else, the architectural ideas are the real value. The command pattern, pluggable strategies, and schema-validated actions are patterns you can adopt in TypeScript today to make your agents more testable, observable, and maintainable.&lt;/p&gt;
&lt;p&gt;&amp;lt;div id=&quot;glossary-data&quot; style=&quot;display:none;&quot;&amp;gt;
&amp;lt;div data-glossary-id=&quot;command-pattern&quot;&amp;gt;
&amp;lt;strong&amp;gt;Command Pattern&amp;lt;/strong&amp;gt;
&amp;lt;p&amp;gt;The command pattern is a design pattern where an operation is represented as an object (or data structure) rather than being executed immediately. Instead of calling a function that sends an email, you create a &quot;send email&quot; command object and hand it to something else to execute. This decouples the decision (&quot;what should happen&quot;) from the execution (&quot;make it happen&quot;). It makes it easy to log, queue, undo, replay, or test operations because they are just data until someone runs them.&amp;lt;/p&amp;gt;
&amp;lt;p&amp;gt;&amp;lt;a href=&quot;https://en.wikipedia.org/wiki/Command_pattern&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&amp;gt;Read more: Command pattern, Wikipedia →&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;
&amp;lt;/div&amp;gt;
&amp;lt;div data-glossary-id=&quot;fsm&quot;&amp;gt;
&amp;lt;strong&amp;gt;Finite State Machine (FSM)&amp;lt;/strong&amp;gt;
&amp;lt;p&amp;gt;A finite state machine is a model where a system can only be in one state at a time, and transitions between states are triggered by specific events. Think of a traffic light: it is either red, yellow, or green, and it moves between those states in a defined order. In agent systems, FSMs are useful for modelling workflows where an agent should only do certain things depending on what stage it is in. For example, a support agent might move through states like &quot;gathering info&quot;, &quot;looking up order&quot;, and &quot;resolving issue&quot;, with rules about which transitions are allowed.&amp;lt;/p&amp;gt;
&amp;lt;p&amp;gt;&amp;lt;a href=&quot;https://en.wikipedia.org/wiki/Finite-state_machine&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&amp;gt;Read more: Finite-state machine, Wikipedia →&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;
&amp;lt;/div&amp;gt;
&amp;lt;div data-glossary-id=&quot;react&quot;&amp;gt;
&amp;lt;strong&amp;gt;ReAct (Reasoning + Acting)&amp;lt;/strong&amp;gt;
&amp;lt;p&amp;gt;ReAct is a prompting strategy where the LLM alternates between reasoning about the current situation and taking an action (like calling a tool). The loop looks like: think about what to do, do it, observe the result, think again. Most tool-calling agent loops today follow this pattern, even if they do not call it ReAct explicitly.&amp;lt;/p&amp;gt;
&amp;lt;p&amp;gt;&amp;lt;a href=&quot;https://arxiv.org/abs/2210.03629&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&amp;gt;Read more: ReAct: Synergizing Reasoning and Acting in Language Models (Yao et al., 2022) →&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;
&amp;lt;/div&amp;gt;
&amp;lt;div data-glossary-id=&quot;cot&quot;&amp;gt;
&amp;lt;strong&amp;gt;Chain-of-Thought (CoT)&amp;lt;/strong&amp;gt;
&amp;lt;p&amp;gt;Chain-of-Thought prompting asks the LLM to show its working. Instead of jumping straight to an answer, the model generates intermediate reasoning steps. For example, instead of answering &quot;what is 23 minus 7 plus 12&quot; with &quot;28&quot;, it would say &quot;23 minus 7 is 16, then 16 plus 12 is 28&quot;. This improves accuracy on tasks that require multi-step reasoning like maths, logic, and planning.&amp;lt;/p&amp;gt;
&amp;lt;p&amp;gt;&amp;lt;a href=&quot;https://arxiv.org/abs/2201.11903&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&amp;gt;Read more: Chain-of-Thought Prompting Elicits Reasoning in Large Language Models (Wei et al., 2022) →&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;
&amp;lt;/div&amp;gt;
&amp;lt;div data-glossary-id=&quot;tot&quot;&amp;gt;
&amp;lt;strong&amp;gt;Tree-of-Thoughts (ToT)&amp;lt;/strong&amp;gt;
&amp;lt;p&amp;gt;Tree-of-Thoughts extends Chain-of-Thought by exploring multiple reasoning paths in parallel rather than following a single chain. The model generates several possible next steps, evaluates which ones are most promising, and can backtrack if a path leads nowhere. Think of it as the difference between walking down one corridor and mapping out a maze. More expensive (more LLM calls) but significantly better on tasks that require planning or search.&amp;lt;/p&amp;gt;
&amp;lt;p&amp;gt;&amp;lt;a href=&quot;https://arxiv.org/abs/2305.10601&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&amp;gt;Read more: Tree of Thoughts: Deliberate Problem Solving with Large Language Models (Yao et al., 2023) →&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;
&amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;script&amp;gt;
document.addEventListener(&quot;DOMContentLoaded&quot;, () =&amp;gt; {
let activeCard = null;&lt;/p&gt;
&lt;p&gt;document.querySelectorAll(&quot;.glossary-link&quot;).forEach((link) =&amp;gt; {
link.addEventListener(&quot;click&quot;, (e) =&amp;gt; {
e.preventDefault();
const key = link.getAttribute(&quot;data-glossary&quot;);
const source = document.querySelector(&lt;code&gt;[data-glossary-id=&quot;${key}&quot;]&lt;/code&gt;);
if (!source) return;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  // If clicking the same term, toggle it off
  if (activeCard &amp;amp;&amp;amp; activeCard.getAttribute(&quot;data-active-glossary&quot;) === key) {
    activeCard.style.maxHeight = activeCard.scrollHeight + &quot;px&quot;;
    requestAnimationFrame(() =&amp;gt; {
      activeCard.style.maxHeight = &quot;0&quot;;
      activeCard.style.opacity = &quot;0&quot;;
    });
    setTimeout(() =&amp;gt; { activeCard.remove(); activeCard = null; }, 200);
    return;
  }

  // Remove existing card
  if (activeCard) {
    activeCard.remove();
    activeCard = null;
  }

  // Find the closest block-level parent (p, li, etc.)
  const block = link.closest(&quot;p&quot;) || link.closest(&quot;li&quot;) || link.parentElement;

  // Create the card
  const card = document.createElement(&quot;div&quot;);
  card.setAttribute(&quot;data-active-glossary&quot;, key);
  card.innerHTML = source.innerHTML;
  card.style.cssText = `
    margin: 0.75rem 0 1rem 0;
    padding: 1rem 1.25rem;
    border-left: 3px solid #3b82f6;
    border-radius: 0.375rem;
    background: #f0f4ff;
    font-size: 0.925rem;
    line-height: 1.6;
    overflow: hidden;
    max-height: 0;
    opacity: 0;
    transition: max-height 0.25s ease, opacity 0.2s ease;
  `;

  // Dark mode
  if (document.documentElement.classList.contains(&quot;dark&quot;)) {
    card.style.background = &quot;#1e293b&quot;;
    card.style.borderLeftColor = &quot;#60a5fa&quot;;
  }

  // Style inner elements
  card.querySelectorAll(&quot;p&quot;).forEach((p) =&amp;gt; { p.style.margin = &quot;0.4rem 0&quot;; });
  card.querySelector(&quot;strong&quot;).style.display = &quot;block&quot;;
  card.querySelector(&quot;strong&quot;).style.marginBottom = &quot;0.25rem&quot;;

  block.after(card);
  activeCard = card;

  // Animate in
  requestAnimationFrame(() =&amp;gt; {
    card.style.maxHeight = card.scrollHeight + &quot;px&quot;;
    card.style.opacity = &quot;1&quot;;
  });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;});
});
&amp;lt;/script&amp;gt;&lt;/p&gt;
&lt;h2&gt;Official References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://jido.run&quot;&gt;Jido Framework&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://jido.run/blog/jido-2-0-release&quot;&gt;Jido 2.0 Release Post&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://elixir-lang.org&quot;&gt;Elixir Language&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.erlang.org/blog/a-brief-beam-primer&quot;&gt;BEAM VM Overview&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Command_pattern&quot;&gt;Command Pattern, Wikipedia&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>Railway CDN Caching Incident: When Opt-In Becomes Opt-Everyone-In</title><link>https://joshuabellew.com/posts/railway-cdn-caching-incident-march-2026/</link><guid isPermaLink="true">https://joshuabellew.com/posts/railway-cdn-caching-incident-march-2026/</guid><description>Deep dive into Railway&apos;s March 2026 CDN caching incident: how a configuration change leaked authenticated responses across users, what went wrong, and how to prevent it</description><pubDate>Sun, 29 Mar 2026 23:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Overview&lt;/h2&gt;
&lt;p&gt;On March 30th, 2026, Railway experienced an incident where a CDN configuration update accidentally enabled caching for domains that had not enabled CDN. For 52 minutes, HTTP GET responses were incorrectly cached across ~0.05% of Railway domains with CDN disabled. Railway says cached responses may have been served to users other than the original requester, including authenticated responses without &lt;code&gt;Set-Cookie&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;TLDR: A configuration change to Railway&apos;s CDN provider &lt;strong&gt;accidentally enabled caching on domains that had not enabled CDN&lt;/strong&gt;. Authenticated responses (without &lt;code&gt;Set-Cookie&lt;/code&gt; headers) were cached at the edge and may have been served to other users. &lt;code&gt;Cache-Control&lt;/code&gt; directives were respected where set, but most GET responses without explicit cache headers were cached by default.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.railway.com/p/incident-report-march-30-2026-accidental-cdn-caching&quot;&gt;Official incident report&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;Timeline of Events&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;Mar 30, 10:42 UTC - CDN configuration update deployed
                     Accidentally enables caching for CDN-disabled domains
    ↓
Mar 30, 10:42-11:34 UTC - 52-minute window
                           Authenticated GET responses cached and served
                           to wrong users across ~0.05% of domains
    ↓
Mar 30, 11:34 UTC - Issue identified and change reverted
                     All cached assets purged globally
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;How Railway&apos;s CDN Works&lt;/h2&gt;
&lt;p&gt;Railway offers CDN caching as an opt-in feature. When enabled, your application&apos;s responses are cached at edge servers around the world for faster delivery. When disabled, requests route directly to your application with no caching layer in between.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────────┐
│                    CDN Enabled (Opt-In)                     │
│                                                             │
│  User Request                                               │
│       │                                                     │
│       ▼                                                     │
│  ┌──────────┐    Cache     ┌──────────────┐                 │
│  │  CDN     │────HIT──────▶│  Serve from  │                 │
│  │  Edge    │              │  edge cache  │                 │
│  │  Server  │              └──────────────┘                 │
│  │          │                                               │
│  │          │    Cache     ┌──────────────┐                 │
│  │          │────MISS─────▶│  Forward to  │                 │
│  └──────────┘              │  origin app  │                 │
│                            └──────────────┘                 │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│                   CDN Disabled (Default)                    │
│                                                             │
│  User Request                                               │
│       │                                                     │
│       ▼                                                     │
│  ┌──────────────────────────────────────────┐               │
│  │  Direct passthrough to origin app        │               │
│  │  No caching, no edge servers involved    │               │
│  └──────────────────────────────────────────┘               │
└─────────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The distinction matters. There are plenty of reasons to keep CDN off:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Your application returns user-specific or authenticated data that should never be shared.&lt;/li&gt;
&lt;li&gt;Your origin is fast enough and you have no need for edge caching.&lt;/li&gt;
&lt;li&gt;Your responses are highly dynamic and caching would add complexity without meaningful performance gains.&lt;/li&gt;
&lt;li&gt;You want full control over response handling at the application level.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;What Went Wrong&lt;/h2&gt;
&lt;p&gt;The configuration change flipped the caching behavior for domains that should have been in passthrough mode. Here is what the CDN did during the 52-minute window:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────────────┐
│              During the Incident (10:42 - 11:34 UTC)            │
│                                                                 │
│  User A: GET /api/dashboard                                     │
│       │                                                         │
│       ▼                                                         │
│  ┌──────────┐                                                   │
│  │  CDN     │  1. Cache MISS (first request)                    │
│  │  Edge    │  2. Forwards to origin app                        │
│  │  Server  │  3. Receives response with User A&apos;s data          │
│  │          │  4. CACHES the response &amp;lt;-- Should not happen!    │
│  └──────────┘                                                   │
│                                                                 │
│  User B: GET /api/dashboard (same URL, seconds later)           │
│       │                                                         │
│       ▼                                                         │
│  ┌──────────┐                                                   │
│  │  CDN     │  1. Cache HIT                                     │
│  │  Edge    │  2. Returns User A&apos;s cached response              │
│  │  Server  │  3. User B sees User A&apos;s data  &amp;lt;-- Cross-user     │
│  │          │                                    data exposure! │
│  └──────────┘                                                   │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The above is an illustrative hypothetical. Railway did not disclose specific endpoints or data types involved, but this pattern shows how cross-user data exposure could occur.&lt;/p&gt;
&lt;p&gt;There are two important details from Railway&apos;s report:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;Set-Cookie&lt;/code&gt; headers were not cached.&lt;/strong&gt; Railway says &lt;code&gt;Set-Cookie&lt;/code&gt; response headers were not cached during the incident. This means session cookies in response headers were not replayed from cache, but response bodies (which could contain user-specific data) may have been.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;Cache-Control&lt;/code&gt; directives were respected.&lt;/strong&gt; If your application explicitly set &lt;code&gt;Cache-Control: no-store&lt;/code&gt; or &lt;code&gt;Cache-Control: private&lt;/code&gt;, the CDN honoured that. The problem was that most GET responses without explicit cache headers were cached by default.&lt;/p&gt;
&lt;h2&gt;The Real Problem: Cache-Control Defaults&lt;/h2&gt;
&lt;p&gt;This is the core of the issue. Most web frameworks do not set &lt;code&gt;Cache-Control&lt;/code&gt; headers on API responses by default. Developers rarely think about caching headers on authenticated endpoints because they assume the response will go directly to the requesting client.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// A typical Express API endpoint
// No Cache-Control header set, because why would you?
// The CDN is supposed to be OFF.
app.get(&quot;/api/dashboard&quot;, authMiddleware, async (req, res) =&amp;gt; {
    const user = await getUser(req.userId);
    res.json({
        name: user.name,
        email: user.email,
        billing: user.billingInfo,
    });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When the CDN is disabled, this is perfectly fine. The response goes straight from your app to the user. But the moment a CDN is inserted into the path, even accidentally, the lack of &lt;code&gt;Cache-Control&lt;/code&gt; headers becomes a vulnerability.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// What Railway&apos;s CDN saw for a GET request without Cache-Control:
//
// Response headers:
//   Content-Type: application/json
//   (no Cache-Control header)
//
// CDN behavior during this incident: &quot;No cache directive? I&apos;ll cache it.&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;During this incident, Railway&apos;s CDN treated GET responses without explicit cache directives as cacheable. The exact default caching behaviour varies by CDN provider and configuration, but the broader point holds: if you are not setting &lt;code&gt;Cache-Control&lt;/code&gt; headers, you are leaving the caching decision up to whatever intermediary sits in the request path. Railway&apos;s users never opted into the CDN in the first place, so they had no reason to think about this.&lt;/p&gt;
&lt;h2&gt;How a Managed CDN Decides What to Cache&lt;/h2&gt;
&lt;p&gt;Understanding CDN caching decisions helps explain why this went wrong. Below is a simplified model of the decision points that were relevant during this incident. Note that exact behaviour varies by CDN provider and configuration.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Incoming Response from Origin
         │
         ▼
┌─────────────────────────┐
│ Is caching enabled for  │──── NO ─────▶ Passthrough (no caching)
│ this domain/route?      │
└─────────────────────────┘
         │ YES
         ▼
┌─────────────────────────┐
│ Has Cache-Control:      │
│ no-store or private?    │──── YES ────▶ Do NOT cache
└─────────────────────────┘
         │ NO
         ▼
┌─────────────────────────┐
│ Has Cache-Control:      │
│ max-age or s-maxage?    │──── YES ────▶ Cache for specified duration
└─────────────────────────┘
         │ NO
         ▼
┌─────────────────────────┐
│ Has Expires header?     │──── YES ────▶ Cache until expiry
└─────────────────────────┘
         │ NO
         ▼
┌─────────────────────────┐
│ Is it a GET request?    │──── YES ────▶ Apply default caching &amp;lt;-- HERE
└─────────────────────────┘
         │ NO
         ▼
     Do NOT cache
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Per RFC 9111 §3.5, a spec-compliant shared cache must not reuse a response to a request containing an &lt;code&gt;Authorization&lt;/code&gt; header unless the response explicitly allows it (e.g. via &lt;code&gt;public&lt;/code&gt;, &lt;code&gt;s-maxage&lt;/code&gt;, or &lt;code&gt;must-revalidate&lt;/code&gt;). However, managed CDN products do not always follow this strictly, and Railway&apos;s CDN cached authenticated responses during this incident.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That bottom path, &quot;apply default caching&quot;, is what caught Railway&apos;s users. Their applications were not setting cache headers because they were never meant to go through a CDN. When the CDN was accidentally enabled, the responses fell through to the default caching behaviour.&lt;/p&gt;
&lt;h2&gt;What Could Have Prevented This&lt;/h2&gt;
&lt;h3&gt;1. Defence in Depth: Always Set Cache-Control Headers&lt;/h3&gt;
&lt;p&gt;Even if you think your responses will never be cached, set &lt;code&gt;Cache-Control&lt;/code&gt; headers on authenticated endpoints. This is defence in depth. You are not just relying on infrastructure configuration being correct, you are telling any intermediary in the request path how to handle your response.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Middleware that sets Cache-Control on all authenticated routes
function noCacheMiddleware(
    req: Request,
    res: Response,
    next: NextFunction
) {
    res.set(&quot;Cache-Control&quot;, &quot;no-store&quot;);
    next();
}

// Apply to all authenticated routes
app.use(&quot;/api&quot;, authMiddleware, noCacheMiddleware);

// Or per-route
app.get(&quot;/api/dashboard&quot;, authMiddleware, noCacheMiddleware, async (req, res) =&amp;gt; {
    const user = await getUser(req.userId);
    res.json({
        name: user.name,
        email: user.email,
    });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The key directives to know:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;no-store&lt;/code&gt;: Do not store this response in any cache, ever. This is the strongest directive and is sufficient on its own for sensitive authenticated responses.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;private&lt;/code&gt;: Only the end user&apos;s browser may cache this. No shared caches (CDNs, proxies). Useful if you want browser caching but not CDN caching.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;no-cache&lt;/code&gt;: You may store it, but you must revalidate with the origin before serving.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For sensitive authenticated API responses, &lt;code&gt;Cache-Control: no-store&lt;/code&gt; is the simplest and most effective choice. If you want to allow browser caching but prevent shared/CDN caching, use &lt;code&gt;Cache-Control: private, no-cache&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;2. Understanding Cache Keys&lt;/h3&gt;
&lt;p&gt;A cache key is how a CDN decides whether it has already seen a request before. By default, most CDNs construct the cache key from the HTTP method and the URL, nothing else.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Default Cache Key Construction
──────────────────────────────

  Request from User A:  GET /api/dashboard
  Cache key:            GET:/api/dashboard

  Request from User B:  GET /api/dashboard
  Cache key:            GET:/api/dashboard  &amp;lt;-- Same key, same cached response

  The CDN does not look at:
  - Authorization header
  - Cookie header
  - Any other request header
  (unless explicitly told to via Vary or CDN-specific config)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is exactly why Railway&apos;s incident leaked data. The CDN saw &lt;code&gt;GET /api/dashboard&lt;/code&gt; from User A, cached the response, and then served that same cached response to User B because the cache key was identical. The CDN had no way to know these were different users, because nothing in the cache key distinguished them.&lt;/p&gt;
&lt;p&gt;You can influence cache keys in two ways: through the &lt;code&gt;Vary&lt;/code&gt; response header (standard HTTP), or through CDN-specific configuration (e.g. Cloudflare&apos;s Cache Key settings, CloudFront&apos;s cache policies).&lt;/p&gt;
&lt;h3&gt;3. Vary Header for Cacheable Variants&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;Vary&lt;/code&gt; header tells caches to include additional request headers in the cache key. This is useful when you &lt;strong&gt;intentionally&lt;/strong&gt; want to cache responses that differ per some request attribute (e.g. &lt;code&gt;Accept-Language&lt;/code&gt;, &lt;code&gt;Accept-Encoding&lt;/code&gt;).&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Example: intentionally caching public content that varies by language
app.get(&quot;/api/articles&quot;, async (req, res) =&amp;gt; {
    res.set(&quot;Vary&quot;, &quot;Accept-Language&quot;);
    res.set(&quot;Cache-Control&quot;, &quot;public, max-age=3600&quot;);

    const articles = await getArticles(req.headers[&quot;accept-language&quot;]);
    res.json(articles);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With &lt;code&gt;Vary: Accept-Language&lt;/code&gt;, the cache key changes from just the URL to the URL plus the language header. Users requesting different languages get separate cache entries.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; &lt;code&gt;Vary&lt;/code&gt; is &lt;strong&gt;not&lt;/strong&gt; the right tool for protecting personalized authenticated responses. For those, &lt;code&gt;Cache-Control: no-store&lt;/code&gt; is the correct approach. Do not rely on &lt;code&gt;Vary: Authorization&lt;/code&gt; or &lt;code&gt;Vary: Cookie&lt;/code&gt; as a security mechanism. These headers can explode cache cardinality, behave inconsistently across CDN providers, and do not prevent caching, they just partition it. If a response should never be shared between users, prevent caching entirely.&lt;/p&gt;
&lt;h3&gt;4. CDN-Level Safeguards&lt;/h3&gt;
&lt;p&gt;From a platform perspective (which is Railway&apos;s responsibility), there are architectural safeguards that can prevent this class of issue:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────────────┐
│                   CDN Configuration Safeguards                  │
│                                                                 │
│  1. Separate CDN Configs                                        │
│     ┌──────────────┐         ┌──────────────┐                   │
│     │ CDN-Enabled  │         │ CDN-Disabled │                   │
│     │ Config Pool  │         │ Config Pool  │                   │
│     │              │         │              │                   │
│     │ cache: true  │         │ cache: false │                   │
│     │ edge: active │         │ passthrough  │                   │
│     └──────────────┘         └──────────────┘                   │
│     Changes to one pool cannot affect the other                 │
│                                                                 │
│  2. Canary Rollouts                                             │
│     Deploy to 1% ──▶ Monitor ──▶ 10% ──▶ Monitor ──▶ 100%       │
│     (hours, not minutes)                                        │
│                                                                 │
│  3. Automated Cache Behaviour Tests                             │
│     Before every config change:                                 │
│     - Verify CDN-disabled domains return no cache headers       │
│     - Verify CDN-enabled domains cache correctly                │
│     - Verify Set-Cookie responses are never cached              │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Railway&apos;s post-mortem mentions they have already rolled out additional tests for correct/incorrect caching behaviours, and moved to aggressive sharding of CDN rollouts over hours instead of minutes. Both of these directly address the problem. Railway also said users with affected domains would be notified by email.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Railway&apos;s report does not disclose the CDN provider, exact cache key construction, TTLs, authentication mechanisms involved, or the number of incorrect responses served. The analysis sections above are general HTTP caching guidance rather than Railway-confirmed implementation details.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;5. Framework-Level Defaults&lt;/h3&gt;
&lt;p&gt;If you are building a web framework or a platform, consider making safe cache defaults the norm rather than the exception:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// A framework that defaults to no-cache for authenticated responses
class SecureRouter {
    authenticatedRoute(
        path: string,
        handler: RequestHandler
    ) {
        return this.router.get(path, (req, res, next) =&amp;gt; {
            // Always set safe defaults for authenticated routes
            if (!res.getHeader(&quot;Cache-Control&quot;)) {
                res.set(&quot;Cache-Control&quot;, &quot;no-store, private&quot;);
            }
            handler(req, res, next);
        });
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the principle of secure defaults. Developers should have to opt &lt;em&gt;in&lt;/em&gt; to caching on authenticated routes, not opt &lt;em&gt;out&lt;/em&gt;.&lt;/p&gt;
&lt;h2&gt;Lessons Learned&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;CDN configuration is a trust boundary.&lt;/strong&gt; Railway called this out explicitly in their report. Domains with CDN disabled should never have content cached. When a single configuration change can cross that boundary, you need both infrastructure-level and application-level defences.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Defaults matter more than you think.&lt;/strong&gt; Railway&apos;s CDN cached GET responses by default when no &lt;code&gt;Cache-Control&lt;/code&gt; header was present. Most web frameworks do not set &lt;code&gt;Cache-Control&lt;/code&gt; on API responses by default. This gap between CDN behaviour and framework defaults is where incidents like this live.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Defence in depth is not optional.&lt;/strong&gt; Setting &lt;code&gt;Cache-Control: no-store&lt;/code&gt; on authenticated endpoints costs nothing and protects against an entire class of infrastructure misconfiguration. It is the &lt;code&gt;WHERE 1=1&lt;/code&gt; of caching, a safety net you hope you never need.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Slow rollouts save incidents.&lt;/strong&gt; Railway has since moved to sharding CDN rollouts over hours instead of minutes. If this change had rolled out to 1% of domains first with monitoring, the 52-minute exposure window would have been much shorter.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;This pattern will keep repeating.&lt;/strong&gt; Any platform that sits behind a CDN is one misconfiguration away from the same issue. The fix is not just better infrastructure processes, it is application-level awareness that your response might be cached by something you did not put there.&lt;/p&gt;
</content:encoded></item></channel></rss>