<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Royal Simpson Pinto</title>
    <description>The latest articles on DEV Community by Royal Simpson Pinto (@royalpinto007).</description>
    <link>https://dev.to/royalpinto007</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F947695%2F6652469e-7e55-4b5b-a816-1f2d54c72b65.jpeg</url>
      <title>DEV Community: Royal Simpson Pinto</title>
      <link>https://dev.to/royalpinto007</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/royalpinto007"/>
    <language>en</language>
    <item>
      <title>Designing an offline-first planner that repairs your day instead of shaming you</title>
      <dc:creator>Royal Simpson Pinto</dc:creator>
      <pubDate>Wed, 09 Sep 2026 09:30:29 +0000</pubDate>
      <link>https://dev.to/royalpinto007/designing-an-offline-first-planner-that-repairs-your-day-instead-of-shaming-you-4b88</link>
      <guid>https://dev.to/royalpinto007/designing-an-offline-first-planner-that-repairs-your-day-instead-of-shaming-you-4b88</guid>
      <description>&lt;p&gt;Most planners I have tried ask for too much before they give anything back. You make an account, you sync to a cloud you did not ask for, and then you get judged: streaks broken, tasks glowing red, a little counter reminding you how many days you missed. The tools that are supposed to reduce the friction of a day end up adding a layer of guilt on top of it.&lt;/p&gt;

&lt;p&gt;I wanted the opposite. So I built &lt;strong&gt;Tiny Day&lt;/strong&gt;, a cozy daily planner for Android. Your day, made manageable. No account, no cloud, no analytics.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea: gentle and local
&lt;/h2&gt;

&lt;p&gt;Two commitments shaped every decision.&lt;/p&gt;

&lt;p&gt;The first is &lt;strong&gt;offline-first&lt;/strong&gt;. Tiny Day is a React Native app built with Expo (SDK 57). There is no application backend at all. You brain-dump your day in plain language, the app shapes it into a timeline, and everything you type lives on your device. That is not a marketing angle bolted on afterward; it is the architecture. Expo Router owns navigation, Zustand stores own the state, and AsyncStorage persists it. When I say planning is deterministic and local, I mean the scheduler runs on your phone with your data and produces the same result every time. No round trip, no server, works on a plane.&lt;/p&gt;

&lt;p&gt;The second is &lt;strong&gt;gentleness&lt;/strong&gt;. Tiny Day never vibrates. It has no streaks. When you do not finish something, it never auto-carries it to tomorrow behind your back. The whole emotional posture of the app is captured in two lines it actually says to you: &lt;em&gt;"Your day has been repaired,"&lt;/em&gt; and at night, &lt;em&gt;"You did enough for today."&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;The flow starts in the morning. You do not fill in a form field by field. You brain-dump, in plain language, and &lt;code&gt;lib/parse.ts&lt;/code&gt; interprets that text into task cards, pulling out a name, a category, a duration, a priority, and time heuristics. From there &lt;code&gt;lib/schedule.ts&lt;/code&gt; does a priority and energy pass and lays out a timeline. Crucially, it protects meals and deliberately keeps free time open. A planner that fills every minute is just a different kind of stress, so leaving gaps on purpose is a feature, not an oversight.&lt;/p&gt;

&lt;p&gt;Priority in Tiny Day is three tiers, and they are shown as a glyph plus a label, never color alone:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;▲ &lt;strong&gt;must&lt;/strong&gt; gets a reminder plus one follow-up&lt;/li&gt;
&lt;li&gt;● &lt;strong&gt;should&lt;/strong&gt; gets a single reminder&lt;/li&gt;
&lt;li&gt;○ &lt;strong&gt;optional&lt;/strong&gt; stays silent&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notifications are local only, scheduled on the device through expo-notifications. There are quiet hours, and a privacy mode that shows a generic "Important reminder due now" instead of leaking the task name onto your lock screen. At most one gentle replan prompt. That is the ceiling on how much the app is allowed to nag you.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Room&lt;/strong&gt; is the part I am fondest of. It is a layered SVG scene (built with react-native-svg) with a window sky, a lamp glow, a tint overlay, and a tiny character. It has four time states plus a rain variant, it follows the real clock, and it brightens as you complete things and dims into lamplight at night. It is ambient feedback that carries no numbers and no pressure.&lt;/p&gt;

&lt;p&gt;Then there is the feature that named the whole design philosophy: &lt;strong&gt;"My day went wrong."&lt;/strong&gt; Real days fall apart. You wake late, a task runs long, you get too tired, an urgent thing lands. Instead of watching the plan turn into a wall of overdue items, you tap what changed. The repair engine in &lt;code&gt;lib/repair.ts&lt;/code&gt; moves optionals, shortens flexibles, protects your ▲ musts and any fixed appointments, and inserts rest. Then it shows you exactly what it is about to change before it applies anything. You stay in control; the app just does the tedious reshuffling. And because repair is deterministic and local like the scheduler, it is fast and predictable.&lt;/p&gt;

&lt;p&gt;The day closes with an &lt;strong&gt;evening&lt;/strong&gt; review: a mood check-in, gentle stats, and leftovers triage where you decide, per item, whether something goes to tomorrow, to the backlog, or you simply let it go. Nothing moves on its own.&lt;/p&gt;

&lt;p&gt;A few things I made sure not to skip. Accessibility is real: 44px and larger touch targets, dynamic type, reduce-motion and high-contrast toggles, and screen-reader labels. There is a Plan tab with tomorrow, an expandable week, a backlog, and a routine builder. And the Profile can load and remove reversible sample data so you can explore the app without polluting your own tasks.&lt;/p&gt;

&lt;h2&gt;
  
  
  One honest limitation
&lt;/h2&gt;

&lt;p&gt;The natural-language parsing is a heuristic, not a language model. &lt;code&gt;lib/parse.ts&lt;/code&gt; reads your brain-dump with rules for durations, categories, priority cues, and time hints. That is exactly why it can run fully offline and stay deterministic, which I consider a fair trade. But it means phrasing the parser has not seen can land in the wrong category or miss a time you clearly implied. The app leans on this by letting you edit any task in place or reschedule it to an explicit future date and time, so a miss is a quick correction rather than a dead end. Still, if you are expecting the free-form understanding of a cloud AI assistant, this is a simpler, more predictable engine, and I would rather be honest about that than oversell it.&lt;/p&gt;

&lt;p&gt;There is also a practical packaging note: the current v1.1.0 APK is ARM64-only, which covers most modern phones and tablets but means a 32-bit-only device needs a separately configured build. Tiny Day targets Android 7.0 (API 24) and newer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Tiny Day is open source under the MIT license. It is a fully offline React Native app: no account, no cloud, no analytics, and no vibration. If a planner that repairs your day instead of shaming you sounds like the kind of tool you have been missing, the code and the latest Android release are here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/royalpinto007/Tiny-Day" rel="noopener noreferrer"&gt;https://github.com/royalpinto007/Tiny-Day&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You did enough for today.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>typescript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Delivering messages with no internet, no servers, and no SIM</title>
      <dc:creator>Royal Simpson Pinto</dc:creator>
      <pubDate>Mon, 07 Sep 2026 09:30:29 +0000</pubDate>
      <link>https://dev.to/royalpinto007/delivering-messages-with-no-internet-no-servers-and-no-sim-50b1</link>
      <guid>https://dev.to/royalpinto007/delivering-messages-with-no-internet-no-servers-and-no-sim-50b1</guid>
      <description>&lt;p&gt;Every messenger you use has a hidden dependency: a working network path to a datacenter. Drop into a basement, a packed stadium, a moving train through a tunnel, an exam hall with jammers, or a remote area with no plan, and the app is just a spinner. The people you want to reach are often standing a few meters away, but your message still has to travel to a server on another continent and back. When that path is gone, so is the app.&lt;/p&gt;

&lt;p&gt;Kabootar is my attempt to remove that dependency entirely. It is a messenger with no backend at all. Your phone forms a peer-to-peer mesh with other phones nearby, and messages hop device to device over Bluetooth and Wi-Fi until they reach the recipient. No internet, no servers, no SIM. It is built in Flutter, and the routing core is plain Dart.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea: delay-tolerant networking
&lt;/h2&gt;

&lt;p&gt;The insight that makes this work is refusing to assume the recipient is reachable &lt;em&gt;right now&lt;/em&gt;. Normal networking is connection-oriented: open a path end to end, then send. If there is no path, there is no delivery.&lt;/p&gt;

&lt;p&gt;Kabootar instead treats the network as a delay-tolerant network (DTN). A message does not need a live end-to-end path at the moment you hit send. It needs a &lt;em&gt;chain of carriers that will exist over time&lt;/em&gt;. You hand your message to whoever is nearby. They hold onto it, carry it as they walk around, and pass it along to the next phone they meet. Eventually a carrier bumps into the recipient and the message lands, even if that is minutes later and both you and the recipient have long since walked away.&lt;/p&gt;

&lt;p&gt;This is store-and-forward, the same shape as a durable, at-least-once message queue, except the queue is running across a swarm of phones instead of inside a datacenter.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a message actually travels
&lt;/h2&gt;

&lt;p&gt;The routing strategy is epidemic routing: flooding. When you send a message, it spreads to everyone in range like a rumor. Each device that receives it re-broadcasts it onward, so the message replicates through the crowd, taking every path at once. That redundancy is exactly what makes delivery robust in a network where any single link is unreliable and short-lived.&lt;/p&gt;

&lt;p&gt;The whole routing brain lives in one place: a framework-free Dart &lt;code&gt;MeshEngine&lt;/code&gt; with zero Flutter, radio, or database imports. Every phone applies the same small set of rules to each envelope it sees:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;De-dup by message id.&lt;/strong&gt; A message can arrive by many paths, but it is acted on exactly once. This is what stops flood storms and routing loops.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Learn from a &lt;code&gt;hello&lt;/code&gt;.&lt;/strong&gt; The contact list is built from whoever comes near, via a handshake.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deliver if it is for me.&lt;/strong&gt; Save it, show it, and send back an &lt;code&gt;ack&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Receipt on an &lt;code&gt;ack&lt;/code&gt;.&lt;/strong&gt; When the acknowledgement for one of my sent messages makes it back to me, I flip that message to &lt;em&gt;delivered&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Relay and carry otherwise.&lt;/strong&gt; Decrement the time-to-live, cache it, and re-flood it onward.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cap everything.&lt;/strong&gt; TTL, a max-age, and a cache-size bound keep battery and storage in check so a carrier stays honest.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is the entire mesh in six rules. Delivery receipts are genuinely end to end: the &lt;code&gt;ack&lt;/code&gt; epidemically routes back to the original sender the same way the message went out, so the WhatsApp-style double tick means the message really reached the recipient's device, not just some server.&lt;/p&gt;

&lt;p&gt;Because de-dup uses the message id and that seen-set is persisted to SQLite, the system survives restarts. A phone that reboots mid-mesh will not re-flood messages it has already handled. At-least-once delivery becomes effectively exactly-once at the edges.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bluetooth and Wi-Fi as the transport
&lt;/h2&gt;

&lt;p&gt;Under the engine sits a &lt;code&gt;MeshTransport&lt;/code&gt; interface, implemented on top of the platform peer-to-peer stacks (Nearby Connections on Android, Multipeer-style discovery on iOS) through &lt;code&gt;flutter_nearby_connections&lt;/code&gt;. Phones continuously advertise and scan, form short-lived peer links over Bluetooth and Wi-Fi, and flush their carried messages whenever a link comes up.&lt;/p&gt;

&lt;p&gt;The architecture keeps a clean seam. The UI is Material 3 and binds to a single &lt;code&gt;ChatService&lt;/code&gt;, which owns state, the hello handshake, and receipt ticks. &lt;code&gt;ChatService&lt;/code&gt; talks to the pure &lt;code&gt;MeshEngine&lt;/code&gt; for routing decisions, to SQLite for persistence, and to the transport for the actual radios. Because the engine has no framework or hardware dependencies, its behavior is pinned down by tests that run on a laptop with just the Dart SDK. You can watch store-and-forward-across-time play out (recipient offline, a relay carrying, delivery after the recipient returns, sender eventually learning it was delivered) without touching a phone by running &lt;code&gt;dart run tool/engine_check.dart&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Direct chats and private groups are end-to-end encrypted with X25519, Ed25519, and AES-GCM, with signed messages and a safety code to verify a contact. Relays only ever see ciphertext, which matters a lot when your "relay" is a stranger's phone in a crowd.&lt;/p&gt;

&lt;h2&gt;
  
  
  One honest limitation
&lt;/h2&gt;

&lt;p&gt;Mesh delivery is only as good as the crowd. Delivery needs a chain of carriers to physically exist between you and the recipient over some window of time. In a dense place, a festival, a protest, a stadium, that chain forms readily and messages move fast. In a sparse setting with few phones running the app, the chain may be long, slow, or never complete. There is no server to fall back on, so if the carriers are not there, the message waits. This is inherent to any mesh, not a bug I can code away, and I would rather state it plainly than pretend proximity networking is magic.&lt;/p&gt;

&lt;p&gt;There is also a platform wall worth naming: one Flutter codebase runs on both Android and iOS, but a message cannot currently hop across the OS boundary because the two platforms use different peer radios. v1 meshes within an OS family. Forward secrecy is future work too; today's encryption uses long-term static keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I built it
&lt;/h2&gt;

&lt;p&gt;I wanted to prove that resilient, private messaging does not require anyone's cloud. No account, no backend, no data leaving the device. It is the kind of infrastructure that keeps working in dead zones precisely because it never depended on infrastructure in the first place.&lt;/p&gt;

&lt;p&gt;The routing engine is small, provable, and framework-free, which I think makes it a genuinely fun read if you are into distributed systems. It is open source under MIT.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/royalpinto007/Kabootar" rel="noopener noreferrer"&gt;https://github.com/royalpinto007/Kabootar&lt;/a&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>networking</category>
      <category>opensource</category>
    </item>
    <item>
      <title>What a registry of real AI-agent failures reveals about where agents break</title>
      <dc:creator>Royal Simpson Pinto</dc:creator>
      <pubDate>Sat, 05 Sep 2026 09:30:32 +0000</pubDate>
      <link>https://dev.to/royalpinto007/what-a-registry-of-real-ai-agent-failures-reveals-about-where-agents-break-3ceg</link>
      <guid>https://dev.to/royalpinto007/what-a-registry-of-real-ai-agent-failures-reveals-about-where-agents-break-3ceg</guid>
      <description>&lt;p&gt;Every week I read the same story in a slightly different shape. An AI agent deleted a production database. An agent emailed the wrong recipient list. An agent ran up a surprise bill because nobody set a spend limit. These incidents get a viral thread, a few hundred angry replies, and then they vanish. The next team wires up an agent with the exact same missing guardrail, and the exact same thing happens again.&lt;/p&gt;

&lt;p&gt;Agent failures are undocumented and, because they are undocumented, they repeat. That is the problem I wanted to fix. So I built &lt;strong&gt;AgentPostmortem&lt;/strong&gt;, a public registry of AI agent failures. Real incidents, documented and searchable, at agentpostmortem.com.&lt;/p&gt;

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

&lt;p&gt;Aviation has the NTSB. Software has postmortems and status-page retrospectives. AI agents, which are being handed write access to filesystems, inboxes, payment APIs, and cloud consoles, have nothing comparable. There is no shared, structured record of what has actually gone wrong.&lt;/p&gt;

&lt;p&gt;AgentPostmortem is that record. It is a community-driven database of incidents where an AI agent caused real harm: deleted data, sent emails to the wrong people, ran up unexpected bills, exposed credentials, or otherwise went off the rails in production. Cases can be submitted anonymously or with attribution. Every case is reviewed before it goes public, and each approved case gets a permanent identifier in the form APM-XXXX so it can be cited and referenced forever.&lt;/p&gt;

&lt;p&gt;The goal is not to dunk on any particular model or vendor. It is to turn one-off war stories into a corpus you can actually search before you ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  The schema
&lt;/h2&gt;

&lt;p&gt;The value of a registry lives in its schema. If every report is a free-form blog post, you cannot compare or aggregate anything. So the submission is structured and validated. The fields I settled on, enforced with a Zod schema on the server, are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Agent involved&lt;/strong&gt;, chosen from a known registry of agents (Claude, GPT-4, o1, o3, and others), each tied to its company.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Title&lt;/strong&gt;, a concise summary, between 20 and 200 characters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prompt&lt;/strong&gt;, the exact instruction given to the agent, optional, up to 2000 characters. This is often the most instructive field, because the failure frequently lives in the gap between what was asked and what was done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Outcome&lt;/strong&gt;, a full description of what happened and the consequences, required, at least 100 characters and up to 10,000.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Damage level&lt;/strong&gt;, a severity rating from 1 to 5.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Estimated cost in USD&lt;/strong&gt;, an integer, where 0 means the damage was reputational or non-financial.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tags&lt;/strong&gt;, at least one, up to eight, so incidents can be grouped by failure mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evidence&lt;/strong&gt;, up to five screenshots.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Attribution is optional. A submitter can stay fully anonymous, or attach a handle or company name. If they leave an email, it is used only to send a private edit link and is not meant to be stored long-term.&lt;/p&gt;

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

&lt;p&gt;The stack is deliberately boring so the data outlives any hype cycle. It is a Next.js 14 App Router app on Vercel, with Supabase (Postgres with row-level security) as the database, Cloudflare R2 for screenshot storage, and Resend for delivering edit-link emails.&lt;/p&gt;

&lt;p&gt;When you submit a case, it does not appear immediately. The submission goes through moderation. Behind the scenes the API hashes the submitter IP with a fixed pepper for rate limiting and abuse control, runs PII redaction over the text, and stores the case with a pending status. Nothing is published on trust. When a moderator approves a case, it is assigned the next sequential APM number, zero-padded, and only then does it become part of the public feed.&lt;/p&gt;

&lt;p&gt;The public side is built for browsing and discovery. There is a feed with hot, new, and this-week views, individual case pages at a stable URL per case number, agent profile pages, tag pages, a search endpoint, a hall of fame for the highest-voted cases, and a stats page. Visitors can vote, which is what powers the ranking.&lt;/p&gt;

&lt;p&gt;Crucially, the whole corpus is exportable. There is an export endpoint that dumps every approved case as CSV: case number, title, agent, company, damage level, estimated cost, vote score, tags, author, date, and the full outcome text. That matters to me because a registry you cannot get the data out of is just another silo. If someone wants to run their own analysis of agent failure modes, the raw data is one request away.&lt;/p&gt;

&lt;h2&gt;
  
  
  One honest limitation
&lt;/h2&gt;

&lt;p&gt;The hardest problem here is not the code. It is verification. AgentPostmortem relies on people to report incidents accurately, and moderation can confirm that a report is coherent, on-topic, and free of obvious abuse, but it usually cannot independently prove that an incident happened exactly as described. Screenshots help, and attribution helps, but a determined person could still submit a plausible fabrication, and an anonymous submitter cannot always be followed up with.&lt;/p&gt;

&lt;p&gt;I decided that structured, moderated, imperfect documentation is still far better than the status quo, which is nothing. The severity and cost fields are self-reported estimates, not audited figures, and they should be read that way. Over time I would rather add verification signals, such as linking to a public source or corroborating reports, than pretend the current bar is higher than it is. For now, treat the registry as a well-organized collection of field reports, not a court record.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I keep working on it
&lt;/h2&gt;

&lt;p&gt;The number of agents with real write access to real systems is only going up. The institutional memory of how they fail is not keeping pace. A shared, searchable, citable record of failures is the kind of infrastructure that gets more valuable with every case added, and it costs the community almost nothing to contribute to.&lt;/p&gt;

&lt;p&gt;If you have watched an agent do something it should never have been allowed to do, that is a case worth documenting. Submissions are open and anonymous submissions are accepted. The code is MIT-licensed and issues and PRs are welcome, including adding new agents to the registry.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/AgentPostmortem/agentpostmortem" rel="noopener noreferrer"&gt;https://github.com/AgentPostmortem/agentpostmortem&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>opensource</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Observability for AI agents without instrumentation: read the transcript</title>
      <dc:creator>Royal Simpson Pinto</dc:creator>
      <pubDate>Fri, 04 Sep 2026 09:30:29 +0000</pubDate>
      <link>https://dev.to/royalpinto007/observability-for-ai-agents-without-instrumentation-read-the-transcript-434d</link>
      <guid>https://dev.to/royalpinto007/observability-for-ai-agents-without-instrumentation-read-the-transcript-434d</guid>
      <description>&lt;p&gt;Most observability advice starts the same way: wrap your calls, add a tracer, emit spans, ship them somewhere. That is a lot of work to do before you have learned anything, and it only helps the runs you thought to instrument ahead of time. The run you actually want to understand is usually the one that already happened, the one you did not plan for.&lt;/p&gt;

&lt;p&gt;Here is the thing I kept forgetting: the transcript is already the source of truth. Claude Code writes every session to disk as it goes, at &lt;code&gt;~/.claude/projects/&amp;lt;slug&amp;gt;/&amp;lt;session-id&amp;gt;.jsonl&lt;/code&gt;. Every subagent delegation, every prompt, every result, every tool call. It is all there whether or not you set anything up in advance. So the whole "add instrumentation" step is skippable. You do not need to trace the run. You need to read the file the run already left behind.&lt;/p&gt;

&lt;p&gt;I built two small tools on that idea. They read local files, no API keys, no network, no SDK. One asks "which of these results should I not trust?" and the other asks "what filled up the context window and what can I delete?"&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool one: flagging runs you should not trust
&lt;/h2&gt;

&lt;p&gt;The first tool is &lt;a href="https://github.com/royalpinto007" rel="noopener noreferrer"&gt;agentrace&lt;/a&gt;. Directing agents turned out to be the easy half of the job. The hard half is verification. When you fan out ten background subagents and each returns a confident wall of text, generation is not the bottleneck. Knowing which answers count as proof is. And you cannot verify what nobody reads. By the time the agents report back, the interesting parts are buried in a transcript nobody opens.&lt;/p&gt;

&lt;p&gt;So agentrace reads it for you and flags the suspicious results. Every check exists because it actually bit me, over roughly 150 research subagents across two weeks. A few of the ones that earned their place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;error&lt;/code&gt;: agents dying on session limits mid-sweep, work silently lost, nobody noticing until the final report came back short.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;absence_as_evidence&lt;/code&gt;: an agent concluded a company was not hiring because an API returned an empty list. That API returns empty with HTTP 200 for accounts that do not even exist. Absence of data is not evidence of absence.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;gave_up&lt;/code&gt;: "I was unable to find..." reads like an answer if you skim. It is not one.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;hedged_claim&lt;/code&gt;: an agent said a company "appears to be" hiring. That hedge quietly became a fact by the time it reached a decision. The hedge was honest; the bug was flattening it downstream.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;unverified_urls&lt;/code&gt;: twenty URLs cited, none opened. That is autocomplete, not research.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;no_output_contract&lt;/code&gt; and &lt;code&gt;thin_prompt&lt;/code&gt;: the failure that is mine, not the model's. A task with no definition of done cannot be verified, because I never really asked the question.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run against the exact session that motivated the tool, 152 subagent runs, it flagged 36 of them, with 39 findings total: 7 agents that died on session limits, 17 hedged claims, and 12 prompts where I forgot to specify an output shape.&lt;/p&gt;

&lt;p&gt;That last number is the one I keep coming back to. Twelve of the flagged runs were not model failures at all. They were me handing an agent a task with no shape and then being surprised the result had no shape. Most agent tooling assumes the model is the problem. A lot of the time the prompt is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool two: profiling the context window
&lt;/h2&gt;

&lt;p&gt;The second tool is &lt;a href="https://github.com/royalpinto007" rel="noopener noreferrer"&gt;ctxlens&lt;/a&gt;, a flamegraph for the context window. Agents get slow, expensive, and frankly dumb when their context fills with junk: the same file read six times, a 12k-token tool result that mattered for exactly one turn, tool schemas re-sent on every single step. Token dashboards tell you the bill. They do not tell you where the bytes went or what to cut.&lt;/p&gt;

&lt;p&gt;ctxlens parses the same transcripts and gives a per-turn breakdown. Every message is attributed to a segment: &lt;code&gt;system&lt;/code&gt;, &lt;code&gt;tool_definitions&lt;/code&gt;, &lt;code&gt;user&lt;/code&gt;, &lt;code&gt;assistant&lt;/code&gt;, &lt;code&gt;thinking&lt;/code&gt;, &lt;code&gt;tool_call&lt;/code&gt;, &lt;code&gt;tool_result&lt;/code&gt;. Then it computes a waste ratio, &lt;code&gt;total_waste / total_tokens&lt;/code&gt;, summing duplicate tokens, tool-result bloat above a cap, stale outputs, and oversized tool definitions. On one session it profiled 12,481 tokens across 14 turns and called out 4,932 of them, 39.5 percent, as waste.&lt;/p&gt;

&lt;p&gt;The recommendations are rule-based and specific, not generic advice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Repeated content&lt;/strong&gt;: the same file or tool result appearing more than once. One session had &lt;code&gt;Read:file_path=config.py&lt;/code&gt; show up six times across turns 2, 5, 7, 9, 11, and 13. Every copy after the first is dead weight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stale tool outputs&lt;/strong&gt;: an older result superseded by a newer one for the same target, still occupying context.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Oversized tool definitions&lt;/strong&gt;: schemas above budget, paid on every turn.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool results dominate&lt;/strong&gt; and &lt;strong&gt;single biggest consumer&lt;/strong&gt;: callouts when one category or one message is eating the window.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each recommendation carries a severity and an estimated token saving, so you can act on the expensive ones first. It works offline with a deterministic heuristic tokenizer for relative profiling and CI, and upgrades to exact BPE counts automatically when &lt;code&gt;tiktoken&lt;/code&gt; is installed. That fallback matters more than it sounds: it means the tool runs anywhere with no heavy dependency, and you only pay for &lt;code&gt;tiktoken&lt;/code&gt; when you need the exact number.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one honest caveat
&lt;/h2&gt;

&lt;p&gt;These are heuristics over text. That is the whole trick and also the whole limitation. agentrace tells you what to go read; it does not tell you what is true. A checker that cries wolf gets switched off, which is worse than no checker at all, so severity is deliberately conservative. &lt;code&gt;thin_prompt&lt;/code&gt; used to fire on any prompt under 200 characters, until I noticed it was flagging perfectly verifiable short prompts and teaching nobody anything. Now a prompt has to be short and never say what done looks like before it fires. On the bundled fixture that dropped findings from 16 to 9 without losing a single true one.&lt;/p&gt;

&lt;p&gt;Same honesty applies to ctxlens. A heuristic tokenizer is great for relative comparisons and catching duplication; it is not the exact bill. Treat both tools as a fast way to point your attention, not as a verdict.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this buys you
&lt;/h2&gt;

&lt;p&gt;No instrumentation. No wrapper. No SDK. The data is already on disk, so you can analyze the run you wish you had traced, after the fact, on any session, including the one still being written (both tools skip a torn final JSON line instead of refusing to parse). That is the part I did not expect going in: the observability was never missing. It was sitting in a &lt;code&gt;.jsonl&lt;/code&gt; file the whole time, waiting for something to read it.&lt;/p&gt;

&lt;p&gt;Both tools are MIT licensed and up at &lt;a href="https://github.com/royalpinto007" rel="noopener noreferrer"&gt;github.com/royalpinto007&lt;/a&gt;. If you run agents at any scale, point them at your &lt;code&gt;~/.claude/projects&lt;/code&gt; directory and see what falls out.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>observability</category>
      <category>python</category>
    </item>
    <item>
      <title>Human-in-the-loop patterns for AI agents: when and how to make an agent stop and ask</title>
      <dc:creator>Royal Simpson Pinto</dc:creator>
      <pubDate>Wed, 02 Sep 2026 09:30:29 +0000</pubDate>
      <link>https://dev.to/royalpinto007/human-in-the-loop-patterns-for-ai-agents-when-and-how-to-make-an-agent-stop-and-ask-fej</link>
      <guid>https://dev.to/royalpinto007/human-in-the-loop-patterns-for-ai-agents-when-and-how-to-make-an-agent-stop-and-ask-fej</guid>
      <description>&lt;p&gt;I build agents that take real actions. Not chatbots that suggest things, but systems that click buttons, issue refunds, and move data around. Once an agent can actually do something, the interesting question stops being "can it decide correctly" and becomes "what happens when it decides wrong." My answer, over and over, has been the same: put a gate in front of the irreversible actions and let a human hold the key.&lt;/p&gt;

&lt;p&gt;That is the whole human-in-the-loop pattern in one sentence. Reads are cheap and reversible, so let them run. Writes are not, so make the agent stop and ask before it commits one. What varies is how you implement the stop, and I have shipped three different versions of it across three projects. Here is what each one taught me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern: a gate before anything you cannot undo
&lt;/h2&gt;

&lt;p&gt;An autonomous agent works because you trust it on the common, safe cases. But autonomy and safety are not opposites you have to trade off against each other. The trick is to split the action space. The safe majority runs automatically. The risky minority pauses and routes to a person. The person only ever sees the decisions that actually need judgment, so the human cost stays low while the blast radius of a bad automated action stays bounded.&lt;/p&gt;

&lt;p&gt;The three implementations below differ in where the gate lives: on your phone, in the agent's request contract, and inside the execution graph itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation 1: approval as a phone tap (Greenlite)
&lt;/h2&gt;

&lt;p&gt;Greenlite is the human-in-the-loop layer as a mobile app. It is an Expo / React Native codebase that builds to native Android and iOS, and its whole job is to be the place where escalations land when you are not at a desk.&lt;/p&gt;

&lt;p&gt;The flow is: an agent wants to do something risky, your phone buzzes, you see the full context and the proposed action, and you approve or deny in one tap. Under the hood it pulls a feed of pending escalations across agents, each carrying a &lt;code&gt;proposedAction&lt;/code&gt; and the reason it escalated. Tapping an item shows the full message and a one-tap Approve / Deny that routes back to the originating agent's approve endpoint. Push notifications come through &lt;code&gt;expo-notifications&lt;/code&gt;, so an agent can buzz the phone the moment it needs a decision.&lt;/p&gt;

&lt;p&gt;What I like about this design is that the approval contract is generic. An item has a &lt;code&gt;proposedAction&lt;/code&gt;, and the backend exposes an &lt;code&gt;/api/approve&lt;/code&gt;. Any new agent that learns to escalate plugs straight into the same feed with no bespoke UI work. The gate is a protocol, not a screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation 2: write-gating in the request contract (Webhands)
&lt;/h2&gt;

&lt;p&gt;Webhands is a computer-use agent for tools that have no usable API. It drives real dashboards (think a seller center or a supplier portal) through Cloudflare Browser Rendering and returns clean structured data plus a screenshot as proof of what it saw.&lt;/p&gt;

&lt;p&gt;Here the gate is baked into the request itself. Every step the agent can take is either a read or a write, and any step marked &lt;code&gt;write: true&lt;/code&gt; (clicking "Issue refund", say) is refused unless the request also includes &lt;code&gt;confirm: true&lt;/code&gt;. So a scraping recipe that logs in and pulls this week's orders runs freely, because it only reads. A recipe that clicks a "confirm shipment" button comes back as an error until you resend it with &lt;code&gt;confirm: true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The important property is the default. Reads are safe by default and writes are deliberate by default. You do not have to remember to add a safety flag; you have to deliberately add a go-ahead flag. That inversion is the whole point. When someone forgets, the system fails closed, not open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation 3: a graph interrupt with a checkpointer (relayg)
&lt;/h2&gt;

&lt;p&gt;relayg is a support triage agent built as a LangGraph state machine. It classifies an incoming ticket, runs pure Python refund policy rules, and then acts. The policy rules decide the gate: refunds under $50 auto-approve, refunds over $200 escalate to a senior agent, and refunds between $50 and $200 pause for human approval.&lt;/p&gt;

&lt;p&gt;That pause is the most technically satisfying version of the gate I have built. When a ticket lands in the $50 to $200 band, the act node calls &lt;code&gt;interrupt()&lt;/code&gt;, which suspends the graph mid-node. The state is checkpointed to SQLite, so the paused run survives a process restart. Later a reviewer resumes it with &lt;code&gt;Command(resume=...)&lt;/code&gt;, and execution continues from the exact same checkpoint with the human verdict injected into state. Every action the agent takes (&lt;code&gt;send_reply&lt;/code&gt;, &lt;code&gt;issue_refund&lt;/code&gt;, &lt;code&gt;escalate&lt;/code&gt;) is a tool that appends to an &lt;code&gt;audit.jsonl&lt;/code&gt; log, so there is a full record of what happened and who approved it.&lt;/p&gt;

&lt;p&gt;This is the difference between "wait for a webhook and stuff the state into a database by hand" and having interrupts as a first-class part of the execution model. The paused run is not a suspended HTTP request holding memory hostage; it is durable state on disk you can resume days later. The interrupt and resume path is covered by the project's pytest suite, because if your safety gate is not tested, it is not a safety gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  When NOT to gate
&lt;/h2&gt;

&lt;p&gt;A gate has a cost, and the cost is a human's attention. If you gate too much, people start rubber-stamping, and a rubber-stamped approval is worse than no gate because it launders a risky action as a reviewed one. So I do not gate reads, ever. I do not gate actions that are trivially reversible. And I do not gate the high-frequency safe cases that make up the bulk of the work. relayg does not pause on a sub-$50 refund, and Webhands does not ask permission to scrape a page. Gating is for the actions where a wrong call actually costs something and cannot be quietly undone.&lt;/p&gt;

&lt;h2&gt;
  
  
  One honest caveat
&lt;/h2&gt;

&lt;p&gt;A human-in-the-loop gate protects you from bad automated actions. It does not protect you from a bad human decision, and it does not make the agent's proposal correct. If the agent proposes the wrong refund and the reviewer is tired and taps Approve, the gate did exactly its job and you still got the wrong outcome. The gate buys you a decision point, not a correct decision. That is why the context you show at the gate matters as much as the gate itself: Greenlite shows the full message, Webhands returns a screenshot, relayg keeps an audit log. A gate with no context is just a slower way to say yes.&lt;/p&gt;

&lt;p&gt;If you want to see any of these end to end, the code is at &lt;a href="https://github.com/royalpinto007" rel="noopener noreferrer"&gt;github.com/royalpinto007&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Pausing a LangGraph Agent Mid-Run for a Human Approval</title>
      <dc:creator>Royal Simpson Pinto</dc:creator>
      <pubDate>Mon, 31 Aug 2026 09:30:29 +0000</pubDate>
      <link>https://dev.to/royalpinto007/pausing-a-langgraph-agent-mid-run-for-a-human-approval-3nde</link>
      <guid>https://dev.to/royalpinto007/pausing-a-langgraph-agent-mid-run-for-a-human-approval-3nde</guid>
      <description>&lt;p&gt;I had a support triage agent already running in production. It was called Resolvd, wired up in n8n with Claude doing the classification, and it worked. So why rebuild the same thing in code? Because there was one behavior the visual workflow made awkward, and I wanted to see it done properly: pausing a running agent in the middle of a decision, holding its entire state on disk, and resuming it only after a human says yes or no.&lt;/p&gt;

&lt;p&gt;That behavior is the whole reason RelayG exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;A triage agent for refunds is mostly boring, and that is good. Someone was double charged $25, you refund it automatically. Someone wants $450 back, that is above your ceiling and a senior agent should look at it. The interesting cases sit in the middle. A $120 refund is too much to hand out on autopilot but too routine to escalate every time. You want a person to glance at it and approve or deny, and you want the agent to wait for that answer without losing its place.&lt;/p&gt;

&lt;p&gt;In a workflow tool you approximate this with webhooks, wait nodes, and state you stuff into a database by hand. It works, but the pause is bolted on. I wanted the pause to be a first-class part of how the agent runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea: the agent is an explicit state machine
&lt;/h2&gt;

&lt;p&gt;The thing I care about most here is that RelayG is not a vague "LLM in a loop." It is a state machine with three named nodes and edges between them, declared up front:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;classify -&amp;gt; policy_check -&amp;gt; act -&amp;gt; END
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;classify&lt;/code&gt; reads the ticket and produces structured labels. &lt;code&gt;policy_check&lt;/code&gt; applies pure Python refund rules. &lt;code&gt;act&lt;/code&gt; takes the action. That is the entire graph, wired in LangGraph with &lt;code&gt;add_node&lt;/code&gt; and &lt;code&gt;add_edge&lt;/code&gt;. Because the shape is explicit, I can read the control flow without running anything, and I can unit test each node in isolation.&lt;/p&gt;

&lt;p&gt;The state that flows through those nodes is typed. It is a &lt;code&gt;TypedDict&lt;/code&gt; carrying the ticket fields, the classification, the policy decision and its reason, and an &lt;code&gt;actions&lt;/code&gt; list that accumulates across nodes via an &lt;code&gt;operator.add&lt;/code&gt; reducer. The classification itself is a Pydantic model with &lt;code&gt;Literal&lt;/code&gt; types for intent and urgency, so "refund_request" is a value the type system knows about rather than a string I hope is spelled right.&lt;/p&gt;

&lt;p&gt;One deliberate choice: the LLM only touches &lt;code&gt;classify&lt;/code&gt;. It extracts intent, urgency, and a refund amount. Every actual decision about money happens in &lt;code&gt;policy_check&lt;/code&gt;, which has no LLM and no I/O:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;AUTO_APPROVE_LIMIT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;          &lt;span class="c1"&gt;# under $50
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auto_approve&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;HUMAN_APPROVAL_LIMIT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="c1"&gt;# $50 to $200
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;needs_approval&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;escalate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;                    &lt;span class="c1"&gt;# over $200
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model reads text. Deterministic rules decide what happens. That split is the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the pause works
&lt;/h2&gt;

&lt;p&gt;The interesting node is &lt;code&gt;act&lt;/code&gt;. When the policy decision is &lt;code&gt;needs_approval&lt;/code&gt;, instead of returning a value, the node calls &lt;code&gt;interrupt()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;interrupt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ticket_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ticket_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;question&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Approve refund of $&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;policy_reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;approved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;interrupt()&lt;/code&gt; suspends execution right there, in the middle of the node. LangGraph writes the current state to a checkpointer and hands the interrupt payload back to whoever invoked the graph. The run is genuinely stopped, not blocked on a thread. If the process died at this moment, the paused ticket would still be sitting in storage.&lt;/p&gt;

&lt;p&gt;The checkpointer in RelayG is a &lt;code&gt;SqliteSaver&lt;/code&gt; over a plain SQLite file, keyed by a &lt;code&gt;thread_id&lt;/code&gt; (I use the ticket id). When a human is ready to answer, you resume by invoking the graph again with a &lt;code&gt;Command&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resume&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;LangGraph loads the checkpoint for that thread, injects &lt;code&gt;verdict&lt;/code&gt; as the return value of the &lt;code&gt;interrupt()&lt;/code&gt; call that was frozen earlier, and the &lt;code&gt;act&lt;/code&gt; node continues from exactly that line. If approved, it issues the refund and records who approved it; if denied, it sends a decline reply carrying the reviewer's note. Every action, refund, reply, or escalation, is appended to an &lt;code&gt;audit.jsonl&lt;/code&gt; log, so after the fact you can see what the agent did and who signed off.&lt;/p&gt;

&lt;p&gt;The demo runs five sample tickets through this. Two of them ($120 and $75) hit the approval gate, pause, and get resumed with simulated reviewer verdicts, one approved and one denied. It runs offline by default: with no &lt;code&gt;GROQ_API_KEY&lt;/code&gt; set, &lt;code&gt;classify&lt;/code&gt; falls back to a deterministic keyword mock, so the interrupt and resume path is fully testable without any API key, and it is covered by pytest.&lt;/p&gt;

&lt;h2&gt;
  
  
  One honest limitation
&lt;/h2&gt;

&lt;p&gt;The tools are mocks. &lt;code&gt;issue_refund&lt;/code&gt;, &lt;code&gt;send_reply&lt;/code&gt;, and &lt;code&gt;escalate&lt;/code&gt; do not call Stripe or an email provider; they write structured entries to the audit log and return. RelayG is a faithful demonstration of the control flow, the typed state, the checkpointing, and the human-in-the-loop mechanism, but wiring the actions to real systems is left as an exercise. The refund thresholds ($50 and $200) are also hardcoded constants rather than per-customer or per-plan policy, which a real deployment would want.&lt;/p&gt;

&lt;p&gt;I am comfortable with that scope, because the part that is genuinely hard to get right in a workflow tool, the durable pause and resume, is the part that is real here.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tradeoff
&lt;/h2&gt;

&lt;p&gt;Rebuilding Resolvd in LangGraph meant giving up the visual editor and the drag-and-drop integrations that made the n8n version fast to assemble. In exchange I got typed state checked at development time, a graph I can read and unit test as plain Python, and interrupts as the core execution model instead of webhook-and-wait plumbing. For an agent that touches money and needs a human in the loop, that felt like the right trade.&lt;/p&gt;

&lt;p&gt;Code, demo, and tests are here: &lt;a href="https://github.com/AgentPostmortem/relayg" rel="noopener noreferrer"&gt;https://github.com/AgentPostmortem/relayg&lt;/a&gt;&lt;/p&gt;

</description>
      <category>langgraph</category>
      <category>python</category>
      <category>ai</category>
      <category>agents</category>
    </item>
    <item>
      <title>Read freely, confirm before writing: a safety model for computer-use agents</title>
      <dc:creator>Royal Simpson Pinto</dc:creator>
      <pubDate>Sat, 29 Aug 2026 09:30:29 +0000</pubDate>
      <link>https://dev.to/royalpinto007/read-freely-confirm-before-writing-a-safety-model-for-computer-use-agents-4i9d</link>
      <guid>https://dev.to/royalpinto007/read-freely-confirm-before-writing-a-safety-model-for-computer-use-agents-4i9d</guid>
      <description>&lt;p&gt;Every few weeks I hit the same wall. A tool I need to pull data from has no usable API. Seller centers, supplier portals, 3PL dashboards. The data is right there on the screen, but the only way to get it is to log in and click through the UI like a human. So I built Webhands: a computer-use agent that operates those dashboards in a real headless browser, returns clean structured data, and refuses any write action unless I explicitly confirm it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with agents that click
&lt;/h2&gt;

&lt;p&gt;The moment you hand an agent a real browser session, you have given it the power to do everything the logged-in human can do. That includes the dangerous stuff. Issuing a refund. Confirming a shipment. Canceling an order. Reading a page is safe. Clicking "Issue refund" is not, and the difference between the two is one button.&lt;/p&gt;

&lt;p&gt;Most automation frameworks treat every action the same way. A click is a click. That is exactly the design choice I did not want, because a scraping run that quietly mutates production state is not a convenience, it is an incident waiting to happen. I wanted reads to be safe by default and writes to be deliberate, with no way to trip into a mutation by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea: recipes and write-gating
&lt;/h2&gt;

&lt;p&gt;You drive Webhands by POSTing a recipe. A recipe is just an entry URL, an optional list of login and navigation steps, and an extraction spec. Here is the shape of a step in the source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Step&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;goto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;selector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;selector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;write&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;waitFor&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;selector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;timeoutMs&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the &lt;code&gt;write?: boolean&lt;/code&gt; on &lt;code&gt;click&lt;/code&gt;. That flag is the whole safety model. Typing, waiting, navigating, and reading are inherently safe. The only action that can change state is a click, so the only action that can carry &lt;code&gt;write: true&lt;/code&gt; is a click. If a step is marked as a write, the request is refused unless it also carries &lt;code&gt;confirm: true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The gate itself is tiny and lives before any browser even launches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;hasWriteStep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recipe&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;confirm&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BROWSER&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;live&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dry&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
    &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;recipe contains a write step; resend with confirm:true to execute&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;hasWriteStep&lt;/code&gt; returns true if any step is a click marked &lt;code&gt;write: true&lt;/code&gt;. When that is the case and &lt;code&gt;confirm&lt;/code&gt; is absent, the function returns an error and never opens the browser. To actually run the write, you resend the exact same recipe with &lt;code&gt;"confirm": true&lt;/code&gt;. Reads never need confirmation because they can never mutate anything.&lt;/p&gt;

&lt;p&gt;I like that this check runs before the browser is provisioned. There is no window where a write step has partially executed and then gets caught. The refusal happens up front, deterministically, based purely on the shape of the recipe.&lt;/p&gt;

&lt;h2&gt;
  
  
  What comes back
&lt;/h2&gt;

&lt;p&gt;On a successful run you get three things. First, &lt;code&gt;data&lt;/code&gt;: structured JSON. You can extract it two ways. Give the recipe a list of &lt;code&gt;fields&lt;/code&gt; with CSS selectors and it scrapes them directly with no model involved. Or give it a natural language &lt;code&gt;extract.prompt&lt;/code&gt; and it hands the page text to Claude and asks for JSON matching your request. Second, &lt;code&gt;screenshotBase64&lt;/code&gt;: a base64 PNG of exactly what the browser saw, so you have proof. Third, &lt;code&gt;steps&lt;/code&gt;: the log of actions it actually took, like &lt;code&gt;goto ...&lt;/code&gt;, &lt;code&gt;type into #email (secret)&lt;/code&gt;, &lt;code&gt;click #signin&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The extraction path is honest about degrading. If no Anthropic key is set, the prompt-based extractor returns the raw text slice instead of failing, so the pipeline still runs in development. When the key is present it calls the Messages API with a Claude Haiku model, asks for JSON only, strips any code fences, and parses. If parsing fails it returns the unparsed text rather than throwing away the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two modes, so you can build without paying
&lt;/h2&gt;

&lt;p&gt;Webhands runs on Cloudflare Workers with Browser Rendering. There are two modes. In &lt;strong&gt;live&lt;/strong&gt; mode the &lt;code&gt;BROWSER&lt;/code&gt; binding is present and it drives a real headless browser via Cloudflare's Puppeteer. In &lt;strong&gt;dry&lt;/strong&gt; mode the binding is absent and it returns the plan it &lt;em&gt;would&lt;/em&gt; have run, labelled as a dry result, instead of executing anything. That means you can author and test recipes without a paid binding, then flip to live when you are ready. Even when the binding exists but Browser Rendering is over quota or unprovisioned, the launch is wrapped so it degrades into a clean error instead of crashing.&lt;/p&gt;

&lt;h2&gt;
  
  
  An honest limitation
&lt;/h2&gt;

&lt;p&gt;The write-gate protects you from executing a mutation you did not confirm. It does not understand what a given click &lt;em&gt;means&lt;/em&gt;. The &lt;code&gt;write: true&lt;/code&gt; flag is set by whoever authors the recipe. If you mark a genuinely destructive button as a read, or forget to mark it at all, Webhands will happily click it without asking, because from its point of view a click without &lt;code&gt;write: true&lt;/code&gt; is just navigation. The safety model is only as good as the labelling. It is a forcing function for deliberate writes, not a classifier that detects danger on its own. I chose that tradeoff deliberately, since guessing intent from a button label is far less reliable than an explicit flag, but it does mean the human writing the recipe still owns the judgment call.&lt;/p&gt;

&lt;p&gt;The other honest caveat is that this operates real UIs. Selectors break when dashboards change, logins get challenged, and a page that renders slowly can time out. Screenshot proof and the step log exist precisely because runs against real portals are messier than runs against an API contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;"There's no API" becomes "there's an agent." Webhands is my attempt to make that swap safe enough to actually use against production dashboards, by making reads free and writes something you have to ask for twice. The recipe format is small on purpose, the gate is a few lines, and both modes let you iterate cheaply before anything touches a live account.&lt;/p&gt;

&lt;p&gt;Code is here: &lt;a href="https://github.com/AgentPostmortem/Webhands" rel="noopener noreferrer"&gt;https://github.com/AgentPostmortem/Webhands&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>automation</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>Putting a one-tap human gate in front of an autonomous agent</title>
      <dc:creator>Royal Simpson Pinto</dc:creator>
      <pubDate>Thu, 27 Aug 2026 09:30:29 +0000</pubDate>
      <link>https://dev.to/royalpinto007/putting-a-one-tap-human-gate-in-front-of-an-autonomous-agent-21l3</link>
      <guid>https://dev.to/royalpinto007/putting-a-one-tap-human-gate-in-front-of-an-autonomous-agent-21l3</guid>
      <description>&lt;p&gt;I build agents that take real actions: resolving support tickets, issuing refunds, replying to customers. The moment those agents stop being demos and start touching money or reputation, the same question shows up every time: what happens on the action that is too risky to automate?&lt;/p&gt;

&lt;p&gt;You have two bad options. Either you let the agent act on everything and hope its judgment holds, or you make it stop and wait for a human. The second is correct, but it usually means a human parked at a dashboard, refreshing a queue. That does not scale, and in practice it means the "human in the loop" is asleep, in a meeting, or nowhere near a laptop when the agent actually needs a decision.&lt;/p&gt;

&lt;p&gt;Greenlite is my answer to that: the human-in-the-loop layer that lives in your pocket. An agent wants to act, your phone buzzes, you see the full context and the proposed action, and you approve or deny in one tap.&lt;/p&gt;

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

&lt;p&gt;The insight is that autonomy is only safe to turn on if the escape hatch is fast. If asking for human approval is expensive, you will avoid asking, and you will end up over-automating. So the whole design goal was to make the approval itself trivial: notification to decision in a couple of taps, wherever you happen to be standing.&lt;/p&gt;

&lt;p&gt;That reframes the agent's job. The agent handles the safe cases automatically and pushes only the rest to a human. A refund under the auto-limit just goes through. A $900 refund, or a message dripping with high-urgency negative sentiment, gets escalated. You stay in control of exactly the decisions that need a person, and nothing else.&lt;/p&gt;

&lt;p&gt;The other design choice was to keep the approval contract generic. An escalation is just an item with a &lt;code&gt;proposedAction&lt;/code&gt;, a reason it escalated, and the underlying detail. Any agent that can produce that shape and expose an approve endpoint plugs straight into the same feed. Greenlite is not tied to one agent, it is a control surface for a whole suite.&lt;/p&gt;

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

&lt;p&gt;Greenlite is one Expo (SDK 51) plus expo-router codebase in TypeScript that builds to native Android and iOS. There are three moving parts: the feed, the decision, and the buzz.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The feed.&lt;/strong&gt; Pending approvals are escalations pulled from each agent's own server-side API. Today that source is my support agent's &lt;code&gt;/api/approvals&lt;/code&gt; endpoint. One thing I want to call out, because it is a deliberate security decision: Greenlite reads from the agent's HTTP API, never from a database directly. The app carries no database credentials. It sends a token in an &lt;code&gt;x-resolvd-token&lt;/code&gt; header and gets back a list of approval items. Each item carries an id, the source agent, a title, the full detail, the proposed action, and the reason it escalated. If the fetch fails, the feed simply comes back empty rather than crashing or guessing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The decision.&lt;/strong&gt; Tap any item and you see the full message plus the proposed action. Approve or deny is one tap. That decision routes back to the originating agent's approve endpoint, a POST to &lt;code&gt;/api/approve&lt;/code&gt; carrying the item id and a boolean. The agent owns what "approved" actually means; Greenlite just relays the human's verdict back to the system that raised the question. Clean separation: the phone is the judgment, the agent is the executor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The buzz.&lt;/strong&gt; Registration uses &lt;code&gt;expo-notifications&lt;/code&gt;. On first run the app requests notification permission, sets up a high-importance "approvals" channel on Android, and fetches an Expo push token. You store that token server-side to target the device, so an agent can buzz the phone the exact moment it needs a decision instead of waiting for you to open the app and check. That is what turns this from a queue you have to remember to check into something that reaches out to you.&lt;/p&gt;

&lt;p&gt;There is also a demo mode. When no backend credentials are configured, the app runs against sample approvals, the $900 refund and the angry customer, so you can explore the whole flow immediately without wiring up an agent first. It made the thing shippable and explorable on day one, which matters for something you want people to actually try.&lt;/p&gt;

&lt;h2&gt;
  
  
  One honest limitation
&lt;/h2&gt;

&lt;p&gt;The approval source is not yet plural in practice. The contract is generic and designed for many agents to feed one queue, but right now the wired-up source is a single agent's endpoint, and the token model reflects that: it is one shared token in the request header, not per-agent auth or per-user identity. For a real multi-agent, multi-approver deployment you would want scoped credentials, an audit trail of who decided what, and probably signed requests so a leaked token is not a master key to approve anything. The mechanism is right; the trust model around it is still early. I would not put this in front of a production money-moving flow without hardening that layer first.&lt;/p&gt;

&lt;p&gt;That trade-off was intentional for where the project is: prove the interaction, prove that notification-to-one-tap-decision feels good and safe, then invest in the auth story once the shape is validated. It is a lot easier to add per-agent tokens to a working approval loop than to design the whole thing around auth you have not tested.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;The thing I keep coming back to is that agent safety is often framed as a model problem, better guardrails, better judgment inside the agent. A lot of it is actually an interface problem. If the human gate is slow, people route around it. If it is one tap on a device you already have on you, keeping a human in the loop stops being a tax and starts being the default. Greenlite is my attempt to make the safe choice the easy one.&lt;/p&gt;

&lt;p&gt;Code and setup are here: &lt;a href="https://github.com/AgentPostmortem/Greenlite" rel="noopener noreferrer"&gt;https://github.com/AgentPostmortem/Greenlite&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>reactnative</category>
      <category>expo</category>
      <category>agents</category>
    </item>
    <item>
      <title>Why inbox AI only drafts, and what it takes to let it act</title>
      <dc:creator>Royal Simpson Pinto</dc:creator>
      <pubDate>Tue, 25 Aug 2026 09:30:29 +0000</pubDate>
      <link>https://dev.to/royalpinto007/why-inbox-ai-only-drafts-and-what-it-takes-to-let-it-act-49gm</link>
      <guid>https://dev.to/royalpinto007/why-inbox-ai-only-drafts-and-what-it-takes-to-let-it-act-49gm</guid>
      <description>&lt;p&gt;Most "AI support" tools stop one step short of being useful. They read the ticket, they draft a nice reply, and then they hand it back to a human who has to re-read the whole thing and click the action anyway. The AI did the easy part. The person still does the work.&lt;/p&gt;

&lt;p&gt;I wanted to see how far past that line I could safely go. Resolvd is my answer: an end-to-end inbox operator that triages, drafts, and acts within a policy. It issues the small refund, sends the order status, closes the ticket, and only escalates the cases that genuinely need a person, with the proposed action already attached so the human decision is one click, not a re-investigation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea: a guardrail between the model and the action
&lt;/h2&gt;

&lt;p&gt;The central design decision is that the language model never decides what happens. It only describes the situation. A separate, deterministic policy layer decides whether the agent may act on its own or must escalate.&lt;/p&gt;

&lt;p&gt;That split matters. If you let the model both classify a ticket and choose to issue a $900 refund, your safety story depends on the model behaving every single time. By putting a plain TypeScript function between the classification and the action, the risky decisions are governed by code I can read, test, and reason about, not by a prompt.&lt;/p&gt;

&lt;p&gt;So the pipeline has two clearly separated stages: triage (the model's job) and decide (the guardrail's job).&lt;/p&gt;

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

&lt;p&gt;A message hits &lt;code&gt;POST /api/inbound&lt;/code&gt;, coming from a helpdesk webhook or an email forwarder. The first stage classifies it. When &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt; is set, Resolvd calls Claude (the Haiku model) with a system prompt that forces JSON-only output: a category (&lt;code&gt;order_status&lt;/code&gt;, &lt;code&gt;refund&lt;/code&gt;, &lt;code&gt;complaint&lt;/code&gt;, or &lt;code&gt;other&lt;/code&gt;), an urgency, a sentiment, an optional refund amount, a one-line summary, and a drafted reply.&lt;/p&gt;

&lt;p&gt;When there is no key, it falls back to a transparent keyword heuristic. This was deliberate. The heuristic scans the subject and body for words like &lt;code&gt;refund&lt;/code&gt;, &lt;code&gt;chargeback&lt;/code&gt;, &lt;code&gt;where&lt;/code&gt;, &lt;code&gt;track&lt;/code&gt;, &lt;code&gt;broken&lt;/code&gt;, &lt;code&gt;angry&lt;/code&gt;, pulls a dollar amount out with a regex, and infers urgency from negative sentiment or words like &lt;code&gt;urgent&lt;/code&gt; and &lt;code&gt;asap&lt;/code&gt;. It is not clever, but it means the service runs and demos without an API key, and it keeps the triage stage honest: nothing downstream should depend on triage being magic. If the Claude call throws, the code logs it and drops to the same heuristic rather than failing the ticket.&lt;/p&gt;

&lt;p&gt;Either way, triage produces the same typed shape, and the guardrail does not care which produced it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the guardrail decides
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;decide&lt;/code&gt; function takes the triage result plus the raw payload and returns a decision: a status of &lt;code&gt;resolved&lt;/code&gt; or &lt;code&gt;escalated&lt;/code&gt;, a proposed action, the action actually taken (only set when auto-resolved), a human-readable reason, and the reply to send. Here is the actual logic, in order.&lt;/p&gt;

&lt;p&gt;First, a hard override: if sentiment is negative and urgency is high, it escalates no matter the category. The proposed action becomes "Personal apology + offer remedy." An angry customer is never something I want an automated system quietly closing out, even if the category looks routine.&lt;/p&gt;

&lt;p&gt;Then it switches on category:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;order_status with an order id&lt;/strong&gt; auto-resolves. This is a read-only lookup, so it is safe to handle without a human. It fetches the status and replies with it. If there is no order id, it escalates with the proposed action "Ask customer for their order number."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;refund at or under the auto-limit&lt;/strong&gt; auto-issues the refund and replies. The limit comes from &lt;code&gt;REFUND_AUTO_LIMIT&lt;/code&gt; and defaults to 50. A refund over the limit escalates with "Approve refund of $X" as the proposed action; a refund with no stated amount escalates asking a human to confirm the amount first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;complaint&lt;/strong&gt; always escalates to a human by policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;other&lt;/strong&gt; escalates as "Human review (uncategorized)" because the system could not confidently categorize it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The dividing line is essentially about reversibility and blast radius. Reading an order's status is harmless. Refunding twenty dollars is bounded and cheap. Refunding nine hundred dollars, or handling an angry complaint, is not something I want a bounded loop doing unsupervised, so those cases go to a person.&lt;/p&gt;

&lt;h2&gt;
  
  
  Escalation carries the proposed action
&lt;/h2&gt;

&lt;p&gt;The part I care most about is what escalation looks like. A traditional tool escalates by handing a human a raw ticket. Resolvd escalates by handing a human a decision that is already made except for the approval: the category, the reason, the drafted reply, and the specific proposed action ("Approve refund of $900"). A human approves or rejects via &lt;code&gt;POST /api/approve&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is the difference between "here, you deal with it" and "here is what I would do, say yes or no." The dashboard reflects this too. It shows every ticket, the auto-resolution rate, the action taken or proposed, and the reason for each. You can see at a glance how much the agent handled on its own and exactly why it made each call.&lt;/p&gt;

&lt;h2&gt;
  
  
  One honest limitation
&lt;/h2&gt;

&lt;p&gt;The actions are not yet wired to real commerce systems. The order lookup is a deterministic stub: it hashes the order id into one of four states (processing, shipped, out for delivery, delivered) so demos stay stable, and the refund path records the decision rather than moving real money. The plumbing for a real deployment (Shopify, an order management system, a payment provider) is a clearly marked seam in the code, not something already connected. So the guardrail logic and the auto-resolve versus escalate flow are real and testable end to end, but the side effects are simulated. I built the decision architecture first on purpose, because that is the part that has to be trustworthy before you ever let it touch production money.&lt;/p&gt;

&lt;p&gt;The stack, for the curious: Next.js 14, Supabase for ticket storage, deployed on Cloudflare Workers via OpenNext.&lt;/p&gt;

&lt;p&gt;If you have ever watched a support tool "help" by making a person do the same work twice, this is my attempt at the opposite. Code is here: &lt;a href="https://github.com/AgentPostmortem/Resolvd" rel="noopener noreferrer"&gt;https://github.com/AgentPostmortem/Resolvd&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>typescript</category>
      <category>nextjs</category>
      <category>automation</category>
    </item>
    <item>
      <title>MCP in practice: building, securing, and serving Model Context Protocol servers</title>
      <dc:creator>Royal Simpson Pinto</dc:creator>
      <pubDate>Sun, 23 Aug 2026 09:30:29 +0000</pubDate>
      <link>https://dev.to/royalpinto007/mcp-in-practice-building-securing-and-serving-model-context-protocol-servers-i36</link>
      <guid>https://dev.to/royalpinto007/mcp-in-practice-building-securing-and-serving-model-context-protocol-servers-i36</guid>
      <description>&lt;p&gt;The Model Context Protocol is the plug that connects an AI agent to real capabilities. Instead of every client inventing its own way to call your tools, an MCP server advertises a list of tools, resources, and prompts over a standard JSON-RPC 2.0 interface, and any MCP client (Claude Code, Cursor, an agent built on the Claude Agent SDK) can discover and call them. That standardization is the whole point, and it is also where the danger lives: the moment you expose a tool, you are handing a language model a lever it can pull on your systems. Over three projects I built a server, scoped one down to per-client permissions, and wrote a scanner to catch the mistakes. Here is what actually mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building a server: the transport is simpler than it looks
&lt;/h2&gt;

&lt;p&gt;My first server, &lt;strong&gt;casebook-mcp&lt;/strong&gt;, turns AgentPostmortem (a public registry of documented AI-agent failures) into something an agent can query mid-investigation. The idea: every team debugging an agent incident is rediscovering failure modes someone already wrote up. So the server exposes four tools: &lt;code&gt;search_cases&lt;/code&gt; for ranked full-text search, &lt;code&gt;get_case&lt;/code&gt; for full case detail, &lt;code&gt;similar_failures&lt;/code&gt; to match an incident description against the corpus, and &lt;code&gt;list_tags&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The lesson here was that you do not need a heavy framework. I implemented the transport directly against the 2025-03-26 streamable HTTP spec in stateless mode: a single &lt;code&gt;POST /mcp&lt;/code&gt; endpoint that handles &lt;code&gt;initialize&lt;/code&gt;, &lt;code&gt;tools/list&lt;/code&gt;, and &lt;code&gt;tools/call&lt;/code&gt;. No sessions, no Durable Objects, no auth, because the data is public and read-only. It runs on a Cloudflare Worker with the protocol routing in one file, the pure ranking logic in another (unit tested on its own), and a data layer that hits the live agentpostmortem.com API with a five-minute in-memory cache and falls back to a bundled dataset when offline. A light per-IP rate limit of 60 requests per minute keeps it polite. You can smoke test it with a single curl of &lt;code&gt;tools/list&lt;/code&gt;, and add it to Claude Code with one &lt;code&gt;claude mcp add --transport http&lt;/code&gt; command.&lt;/p&gt;

&lt;p&gt;The separation that paid off: keeping the search and similarity ranking as pure functions meant I could test the interesting logic without standing up the transport at all. MCP protocol handling is boilerplate. Your actual value is in the tool implementations, so isolate them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scoping a server: not every client should see every tool
&lt;/h2&gt;

&lt;p&gt;Public read-only data is the easy case. The hard case is a server that fronts a company's real systems. &lt;strong&gt;Bridgekit&lt;/strong&gt; does exactly that: it exposes Shopify, Triple Whale, and Postgres to an AI stack, and three of its four tools are reads (&lt;code&gt;shopify_orders&lt;/code&gt;, &lt;code&gt;triplewhale_metrics&lt;/code&gt;, &lt;code&gt;db_query&lt;/code&gt; against an allowlisted table) while one is a write (&lt;code&gt;shopify_tag_order&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The design decision I care about most is that &lt;strong&gt;scope is enforced at discovery, not just at call time&lt;/strong&gt;. Clients are configured in a secret as JSON, each with a name, a list of allowed tools, and an &lt;code&gt;allowWrite&lt;/code&gt; flag. When a client calls &lt;code&gt;tools/list&lt;/code&gt;, the server only advertises the tools that client is scoped for. A read-only client never even sees the write tool exists. Callers authenticate with a bearer key (or an &lt;code&gt;x-bridgekit-key&lt;/code&gt; header), and every attempt is written to an append-only audit log. When a read-only key tries to call the write tool, the call is denied and the denial is logged.&lt;/p&gt;

&lt;p&gt;Two things I would tell anyone building this kind of server. First, per-client tool filtering matters more than you expect, because an agent that cannot see a tool cannot be prompt-injected into calling it. Reducing the advertised surface is a security control, not just tidiness. Second, make it demoable safely: Bridgekit's read tools return clearly-labelled sample data when upstream credentials are not configured, so you can show the whole flow without wiring it to a live store.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scanning a server: assume you got it wrong
&lt;/h2&gt;

&lt;p&gt;After building two servers, I was convinced I would ship a bad tool eventually, and that most people ship them with no security review at all. So I wrote &lt;strong&gt;mcp-audit&lt;/strong&gt;, a scanner and linter for MCP servers. It connects to a server over stdio or HTTP (or lints a static JSON manifest without executing anything, which is what you want for untrusted servers in code review), enumerates every tool, resource, and prompt, and runs 18 rules over that surface.&lt;/p&gt;

&lt;p&gt;The rules cover the failure classes I kept worrying about: arbitrary command or shell execution tools (&lt;code&gt;MCP002&lt;/code&gt;, critical), destructive tools with no confirmation argument (&lt;code&gt;MCP001&lt;/code&gt;), probable prompt-injection text planted in a tool description (&lt;code&gt;MCP020&lt;/code&gt;), secrets or system paths exposed as resources like a &lt;code&gt;.env&lt;/code&gt; file (&lt;code&gt;MCP030&lt;/code&gt;, critical), caller-controlled URL arguments that invite SSRF (&lt;code&gt;MCP041&lt;/code&gt;), HTTP transport with no authentication (&lt;code&gt;MCP040&lt;/code&gt;), and unconstrained input schemas that let the model pass anything anywhere. Each finding has a stable &lt;code&gt;MCPxxx&lt;/code&gt; id, a severity, and a concrete remediation.&lt;/p&gt;

&lt;p&gt;It is built for CI. It runs offline, is fully deterministic, and emits JSON and SARIF 2.1.0 so findings show up as annotations in GitHub code scanning. The process exits non-zero when any finding reaches the &lt;code&gt;--fail-on&lt;/code&gt; threshold (default &lt;code&gt;high&lt;/code&gt;), so a bad audit breaks the build. You can disable noisy rules, remap severities, or ignore specific locations through a &lt;code&gt;.mcpauditrc&lt;/code&gt; file. Running it against my own servers is what turned "I think this is fine" into "the scanner agrees this is fine."&lt;/p&gt;

&lt;h2&gt;
  
  
  One honest caveat
&lt;/h2&gt;

&lt;p&gt;mcp-audit is a static and structural analyzer. It reasons about the shape of your tools: their names, descriptions, and input schemas. It flags a tool named &lt;code&gt;run_shell&lt;/code&gt; and a resource pointing at &lt;code&gt;.env&lt;/code&gt;, but it cannot know that your innocently named &lt;code&gt;update_record&lt;/code&gt; tool quietly runs raw SQL under the hood, because it never sees the implementation. Pattern-matching on descriptions also means it can miss a cleverly worded injection sink or flag a benign one. It narrows the surface an attacker can reach and catches the obvious, dangerous defaults, but it is a first line of defense, not a substitute for reading the code behind each tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  The through-line
&lt;/h2&gt;

&lt;p&gt;Building the transport is the least of it. The work that matters is deciding which tools exist, who is allowed to see them, and proving to yourself that none of them are a foot-gun before an agent finds out for you. All three projects are open source under &lt;a href="https://github.com/royalpinto007" rel="noopener noreferrer"&gt;github.com/royalpinto007&lt;/a&gt;: casebook-mcp, Bridgekit, and mcp-audit. If you are shipping an MCP server, at minimum run a scanner over it first.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>security</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Building a streaming investigation assistant that tool-calls an MCP server and cites case IDs</title>
      <dc:creator>Royal Simpson Pinto</dc:creator>
      <pubDate>Fri, 21 Aug 2026 09:30:28 +0000</pubDate>
      <link>https://dev.to/royalpinto007/building-a-streaming-investigation-assistant-that-tool-calls-an-mcp-server-and-cites-case-ids-455n</link>
      <guid>https://dev.to/royalpinto007/building-a-streaming-investigation-assistant-that-tool-calls-an-mcp-server-and-cites-case-ids-455n</guid>
      <description>&lt;p&gt;I keep a public registry of documented AI-agent failures called AgentPostmortem: real incidents where an agent got prompt-injected, deleted a database, or spun into a runaway loop, each written up as a case file with an ID like APM-0048. The registry is useful, but browsing it is a chore. If I want to know why refund agents get prompt-injected, I have to guess search terms, open cases, and read them one by one.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;casebook-chat&lt;/strong&gt;: a chat interface that does the searching for me. You describe an incident or ask about a failure mode, and the assistant searches the live registry, pulls the relevant case files, and answers in plain language while citing the real case IDs it used. The whole thing runs as a single Cloudflare Worker.&lt;/p&gt;

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

&lt;p&gt;The registry already speaks MCP (Model Context Protocol). It exposes three tools over JSON-RPC at &lt;code&gt;mcp.agentpostmortem.com/mcp&lt;/code&gt;. Rather than reimplement search or copy the data into a new database, I wanted the chat model to call those tools directly and ground its answers in whatever they returned.&lt;/p&gt;

&lt;p&gt;The three tools map cleanly onto how you actually investigate an incident:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;search_cases(query)&lt;/code&gt; runs a full-text search over the failure case files and returns ranked summaries with case IDs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_case(id)&lt;/code&gt; fetches one case in full: outcome, verified facts, unknowns, and lessons.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;similar_failures(description)&lt;/code&gt; takes a plain-language description of an incident and matches it against known failures.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The model decides which to call and when. Ask "why do refund agents get prompt-injected?" and it searches. Say "my agent deleted a database" and it reaches for &lt;code&gt;similar_failures&lt;/code&gt;. Once it has a case ID, it can pull the full detail with &lt;code&gt;get_case&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;The stack is deliberately small. The browser runs a Vite and React app using &lt;code&gt;@ai-sdk/react&lt;/code&gt;'s &lt;code&gt;useChat&lt;/code&gt;, which POSTs the message history to &lt;code&gt;/api/chat&lt;/code&gt;. That endpoint is a Hono route inside a Cloudflare Worker, and the same Worker also serves the static UI through Workers assets. One deploy, one origin, no separate backend.&lt;/p&gt;

&lt;p&gt;Inside the route, I call the Vercel AI SDK's &lt;code&gt;streamText&lt;/code&gt; against Groq's &lt;code&gt;llama-3.3-70b-versatile&lt;/code&gt;, passing the three tools defined with the AI SDK &lt;code&gt;tool()&lt;/code&gt; helper and zod input schemas. Each tool's &lt;code&gt;execute&lt;/code&gt; is just a &lt;code&gt;fetch&lt;/code&gt; to the MCP server wrapping the arguments in a JSON-RPC &lt;code&gt;tools/call&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;search_cases&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Full-text search over documented AI-agent failure cases...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;callMcpTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;search_cases&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;}),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because these are genuine failure investigations, the model often needs more than one hop: search, find a promising case, then fetch its full detail before answering. I allow multi-step tool chains but cap the loop with &lt;code&gt;stopWhen: stepCountIs(5)&lt;/code&gt;, so a model that keeps calling tools cannot run away and burn the whole budget.&lt;/p&gt;

&lt;h3&gt;
  
  
  Streaming with tool calls rendered inline
&lt;/h3&gt;

&lt;p&gt;The nice part is the streaming. The result comes back through &lt;code&gt;toUIMessageStreamResponse()&lt;/code&gt;, which turns the run into a UI message stream where tool inputs and outputs arrive as typed message parts, not just text. On the client, &lt;code&gt;useChat&lt;/code&gt; exposes each assistant message as an ordered list of parts, and I render them in order: text parts become markdown, and anything whose type starts with &lt;code&gt;tool-&lt;/code&gt; becomes an inline activity chip.&lt;/p&gt;

&lt;p&gt;Each chip is a collapsible component that reads the part's &lt;code&gt;state&lt;/code&gt; field to show where the call is. While the model is still forming or running the call (&lt;code&gt;input-streaming&lt;/code&gt;, &lt;code&gt;input-available&lt;/code&gt;), it shows a spinner and the word "running". When the output lands (&lt;code&gt;output-available&lt;/code&gt;) it flips to a checkmark and a one-line summary; on failure (&lt;code&gt;output-error&lt;/code&gt;) it shows a cross and the error text. Expand a chip and you see the exact arguments the model sent and the raw result it got back, pretty-printed. So the tool calls appear woven into the answer as it streams, in the order they actually happened, and you can audit every step.&lt;/p&gt;

&lt;h3&gt;
  
  
  Citations that mean something
&lt;/h3&gt;

&lt;p&gt;Grounding is enforced through the system prompt, not wishful thinking. The investigator is told to answer only from what the tools returned, to cite the real case IDs it used, and never to invent case IDs or facts. If the registry returns nothing relevant, it is instructed to say so plainly and label any general-knowledge answer as such. It also calls tools silently instead of narrating "let me search the registry", so the visible answer stays clean while the chips carry the process.&lt;/p&gt;

&lt;p&gt;The MCP client itself is built to never throw. It retries once on a network error or a 5xx, times out each attempt at around eight seconds, and on persistent failure returns a structured "the registry is temporarily unavailable" string that the model is told to relay to the user rather than crash on. It also handles the fact that the MCP server can answer either as JSON or as a &lt;code&gt;text/event-stream&lt;/code&gt;, parsing the last JSON-RPC frame out of the SSE body when needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  One honest limitation
&lt;/h2&gt;

&lt;p&gt;The grounding is only as strong as the prompt. There is no post-hoc verification step that checks the case IDs in the final answer against the IDs the tools actually returned. The system prompt tells the model not to invent IDs, and in practice llama-3.3-70b on Groq follows that well, but "the model was instructed not to hallucinate" is a softer guarantee than "the app refuses to emit an ID the tools did not surface". If I wanted a hard guarantee, I would parse the assistant's cited IDs and cross-check them against the tool outputs before rendering. That is the honest gap between "cites case IDs" and "provably cannot fabricate a case ID".&lt;/p&gt;

&lt;p&gt;Two smaller notes: inference runs on the Groq free tier, so rate limits and the occasional slowdown apply, and 429s are caught and surfaced as a friendly "the model is busy" message rather than an error. And the registry data is community-documented, so the answers are only as complete as the casebook behind them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;What I like about this build is how little glue it needed. An MCP server that already exposes the right tools, an AI SDK that streams typed tool parts, and a single Worker to host both halves. The result is a chat that does not just talk about agent failures but shows its work: every claim traceable to a case file you can open yourself.&lt;/p&gt;

&lt;p&gt;Code is here: &lt;a href="https://github.com/AgentPostmortem/casebook-chat" rel="noopener noreferrer"&gt;https://github.com/AgentPostmortem/casebook-chat&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>typescript</category>
      <category>cloudflare</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Making a knowledge base queryable by any agent over MCP</title>
      <dc:creator>Royal Simpson Pinto</dc:creator>
      <pubDate>Wed, 19 Aug 2026 09:30:29 +0000</pubDate>
      <link>https://dev.to/royalpinto007/making-a-knowledge-base-queryable-by-any-agent-over-mcp-3lkl</link>
      <guid>https://dev.to/royalpinto007/making-a-knowledge-base-queryable-by-any-agent-over-mcp-3lkl</guid>
      <description>&lt;p&gt;Every team debugging an AI-agent incident is quietly rediscovering a failure mode that someone else already documented. A support bot approves hundreds of fake refunds because a ticket told it to. A retry loop runs away and spams customers. A stale cache turns into a confident hallucination. These are not novel bugs. They are recurring shapes, and the knowledge about how they played out and what fixed them is usually locked in a blog post or a postmortem doc nobody thinks to search at 2am.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://agentpostmortem.com" rel="noopener noreferrer"&gt;AgentPostmortem&lt;/a&gt; as a public registry of these documented failures. But a registry a human has to remember to visit is a registry that goes unused during the exact moment it matters: while an agent is actively investigating. So I built Casebook MCP: a remote &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;MCP&lt;/a&gt; server that turns the registry into tools any agent can call. Now Claude Code, Cursor, or an agent built on the Claude Agent SDK can ask, mid-investigation, "has anything like this happened before?"&lt;/p&gt;

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

&lt;p&gt;Casebook exposes four tools over the Model Context Protocol:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;search_cases(query, tag?)&lt;/code&gt; for ranked full-text search over the case files, with an optional tag filter.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_case(id)&lt;/code&gt; for the full detail of one case: outcome narrative, verified facts, unknowns, and lessons.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;similar_failures(description)&lt;/code&gt; which takes a free-text incident description and returns the closest documented failures by keyword overlap.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;list_tags()&lt;/code&gt; for every failure-mode tag with its description.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tool that earns its keep is &lt;code&gt;similar_failures&lt;/code&gt;. You paste in what is actually happening ("our support bot was tricked by text in a ticket into approving refunds") and get back real precedents, ranked, with the shared keywords, the outcome, and the lessons attached. That is the difference between a lookup table and something an agent can reason against.&lt;/p&gt;

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

&lt;p&gt;The whole thing runs as a single Cloudflare Worker. I chose to implement the MCP transport directly against the 2025-03-26 streamable HTTP spec in stateless mode rather than pull in a framework. There is one &lt;code&gt;POST /mcp&lt;/code&gt; endpoint that handles &lt;code&gt;initialize&lt;/code&gt;, &lt;code&gt;tools/list&lt;/code&gt;, and &lt;code&gt;tools/call&lt;/code&gt;. No sessions, no Durable Objects, no auth, because the data is public and read-only. That decision keeps the server a plain request/response function, which is exactly what a Worker is good at.&lt;/p&gt;

&lt;p&gt;The dispatch layer is just a switch over JSON-RPC methods. Here is the shape of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JsonRpcRequest&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;initialize&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;protocolVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2025-03-26&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;serverInfo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;casebook-mcp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0.1.0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
          &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Use similar_failures when investigating an incident, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
          &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;search_cases for topical research, get_case for full detail.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tools/list&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TOOLS&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tools/call&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;arguments&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;callTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nl"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;notifications/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;32601&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Method not found: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notifications (JSON-RPC requests with no id) get swallowed and produce no response body, which is what the spec wants. The request handler also accepts batched arrays, so a client can pipeline &lt;code&gt;initialize&lt;/code&gt; and &lt;code&gt;tools/list&lt;/code&gt; in one POST.&lt;/p&gt;

&lt;p&gt;The ranking logic lives in its own pure module with no I/O, which makes it trivial to unit test. Scoring is deliberately simple and legible: a query token hitting a case title weighs 3, a tag hit weighs 2, and a body hit weighs 1, with ties broken by case number for stable ordering.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;scoreCase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CaseFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;tokenize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;tokenize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;tokenize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;caseText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tok&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;similar_failures&lt;/code&gt; uses a related but even simpler measure: tokenize the description, tokenize each case, and rank by the size of the keyword intersection. No embeddings, no vector store.&lt;/p&gt;

&lt;p&gt;Data comes from the live public endpoints on agentpostmortem.com (&lt;code&gt;/api/export&lt;/code&gt; for the corpus, &lt;code&gt;/api/search&lt;/code&gt; for rich detail, &lt;code&gt;/api/tags&lt;/code&gt;) behind a 5 minute in-memory cache. When the network is unavailable, it falls back to a bundled dataset of representative case files that ships in the repo and doubles as the deterministic fixture for tests. So the server keeps answering even offline, just from a smaller snapshot.&lt;/p&gt;

&lt;p&gt;There is also a companion investigator agent (&lt;code&gt;agent/investigate.ts&lt;/code&gt;) built on the Claude Agent SDK &lt;code&gt;query()&lt;/code&gt; API. Given an incident description it connects to the MCP server, finds similar failures, pulls the top cases with &lt;code&gt;get_case&lt;/code&gt;, and writes a &lt;code&gt;postmortem-draft.md&lt;/code&gt; grounded in the documented lessons. It runs on your local Claude Code subscription auth, so no API key appears in the code, and it has a &lt;code&gt;--dry-run&lt;/code&gt; mode that stubs the model but still exercises the MCP server end to end, which is what CI uses.&lt;/p&gt;

&lt;h2&gt;
  
  
  An honest limitation
&lt;/h2&gt;

&lt;p&gt;The similarity matching is pure keyword overlap, not semantic search. If your incident description and a documented case describe the same failure in different vocabulary, for example one says "loop" and the other says "recursion", the overlap can miss it. That is a conscious tradeoff: keyword ranking is transparent, dependency-free, and fully unit-testable, and it keeps the Worker cold-start cheap with no vector database to stand up. For a corpus of curated, tagged case files it works well in practice, but I would not claim it generalizes to fuzzy paraphrase the way an embedding index would. Adding an optional embedding pass is the obvious next step if recall becomes the bottleneck.&lt;/p&gt;

&lt;p&gt;The other honest note: the rate limit is a simple in-memory per-IP counter (60 requests per minute) scoped to a single Worker isolate. It resets when the isolate recycles. That is intentionally lightweight for a public read-only endpoint, not a hardened quota system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Add it to Claude Code with a single command once it is running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add &lt;span class="nt"&gt;--transport&lt;/span&gt; http casebook http://localhost:8787/mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then your agent can consult a growing body of real agent failures instead of rediscovering each one from scratch. Code, the four tools, the investigator agent, and the bundled dataset are all here: &lt;a href="https://github.com/AgentPostmortem/casebook-mcp" rel="noopener noreferrer"&gt;github.com/AgentPostmortem/casebook-mcp&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>cloudflare</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
