<?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: Dave Kurian</title>
    <description>The latest articles on DEV Community by Dave Kurian (@davekurian).</description>
    <link>https://dev.to/davekurian</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%2F3962819%2F50b558da-9a83-43ee-a473-5381fe6bb0d4.png</url>
      <title>DEV Community: Dave Kurian</title>
      <link>https://dev.to/davekurian</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/davekurian"/>
    <language>en</language>
    <item>
      <title>AI provider portability: build one model boundary without hiding provider differences</title>
      <dc:creator>Dave Kurian</dc:creator>
      <pubDate>Tue, 01 Sep 2026 09:21:59 +0000</pubDate>
      <link>https://dev.to/davekurian/ai-provider-portability-build-one-model-boundary-without-hiding-provider-differences-110d</link>
      <guid>https://dev.to/davekurian/ai-provider-portability-build-one-model-boundary-without-hiding-provider-differences-110d</guid>
      <description>&lt;p&gt;AI provider portability works when your application owns one stable model boundary while adapters preserve the differences that matter. Do not flatten every provider into a fake universal API. Normalize the inputs and outputs your product actually needs, keep provider-specific options behind an explicit escape hatch, and record which provider produced each result.&lt;/p&gt;

&lt;p&gt;That design lets a production app change vendors, add a fallback, or run an evaluation without rewriting every route and screen. It also avoids the opposite failure: scattering provider-specific request shapes through handlers, background jobs, tests, and UI code until a price or availability change becomes a migration project.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should an AI provider boundary own?
&lt;/h2&gt;

&lt;p&gt;Your boundary should own product behavior, not vendor vocabulary. A useful first version accepts a task, an input, a policy, and an execution context. It returns text or structured output, usage metadata when available, and a typed error when the request fails.&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;AiTask&lt;/span&gt; &lt;span class="o"&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;input&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;system&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;schema&lt;/span&gt;&lt;span class="p"&gt;?:&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="nl"&gt;maxOutputTokens&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="nl"&gt;providerHint&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;AiResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;provider&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;model&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;usage&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;inputTokens&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="nl"&gt;outputTokens&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="p"&gt;};&lt;/span&gt;
  &lt;span class="nl"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;AiProvider&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AiTask&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;AiResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;raw&lt;/code&gt; field is deliberate. If you throw away the original response, debugging and provider-specific features become harder. Keep it out of normal product logic, redact it before persistence, and expose it only to code that explicitly opts in.&lt;/p&gt;

&lt;p&gt;The adapter should translate from this contract into the provider’s request format. OpenAI’s &lt;a href="https://developers.openai.com/api/reference/resources/responses" rel="noopener noreferrer"&gt;Responses API reference&lt;/a&gt; documents response inputs, developer or system instructions, structured input items, background responses, and response output objects. Anthropic’s &lt;a href="https://platform.claude.com/docs/en/api/messages" rel="noopener noreferrer"&gt;Messages API reference&lt;/a&gt; documents alternating user and assistant turns, a top-level system parameter, content blocks, and a required maximum token limit. Those are similar concepts with different shapes. The adapter is where the translation belongs.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you keep the common path small?
&lt;/h2&gt;

&lt;p&gt;Start with the narrowest capability your product needs. If the first feature is “classify a support message into one of four labels,” do not design a universal abstraction for every tool call, image input, citation, and streaming mode on day one.&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AiTask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;support-label&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;messageText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Return one label: billing, bug, account, or other.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;maxOutputTokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;task&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;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseSupportLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The route knows the task name and its acceptance rules. It does not know whether the selected provider expects &lt;code&gt;max_tokens&lt;/code&gt;, &lt;code&gt;max_output_tokens&lt;/code&gt;, a content array, or a different message role. That knowledge stays in the adapter.&lt;/p&gt;

&lt;p&gt;Keep capability declarations next to the adapter:&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;ProviderCapabilities&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;structuredOutput&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="nl"&gt;streaming&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="nl"&gt;toolCalls&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="nl"&gt;imageInput&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="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;RegisteredProvider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;model&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;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AiProvider&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;capabilities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ProviderCapabilities&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;A capability check should fail before a request is sent. If a task needs structured output and the selected provider cannot guarantee it, return a typed configuration error or choose an explicitly approved alternative. Do not silently downgrade to free-form text and hope a parser survives.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should adapters handle provider differences?
&lt;/h2&gt;

&lt;p&gt;Keep one adapter per provider and one translator per capability. Avoid a giant conditional that checks provider names throughout the application.&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OpenAiAdapter&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;AiProvider&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;OpenAiClient&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AiTask&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;AiResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;responses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;configured-model&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="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;system&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;max_output_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxOutputTokens&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;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;openai&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;inputTokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;input_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;outputTokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;output_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client and response fields in this example represent an adapter boundary; use the installed provider client’s current types rather than copying names into a different implementation. The important property is that the rest of the app receives &lt;code&gt;AiResult&lt;/code&gt;, not a vendor response object.&lt;/p&gt;

&lt;p&gt;For a second provider, write a separate adapter that maps its conversation and content format to the same result. Keep provider-specific features explicit:&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="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ProviderOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;common&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;maxOutputTokens&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="p"&gt;};&lt;/span&gt;
  &lt;span class="nl"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;background&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="nl"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cacheControl&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ephemeral&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a route passes &lt;code&gt;anthropic.cacheControl&lt;/code&gt;, it has opted into a provider-specific contract. That is better than pretending the option has identical semantics everywhere. Portability is not sameness; it is controlled change.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you design fallback without duplicate side effects?
&lt;/h2&gt;

&lt;p&gt;Fallback belongs around model calls that are safe to repeat, not around an entire business operation. If a request also charges a card, sends an email, or writes a record, separate those effects from model selection.&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;completeWithFallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AiTask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RegisteredProvider&lt;/span&gt;&lt;span class="p"&gt;[],&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;AiResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;lastError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&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;candidate&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;providers&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;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;schema&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;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;capabilities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;structuredOutput&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;lastError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isRetryableModelError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`No approved provider completed &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;task&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="s2"&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;cause&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lastError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The retry classifier needs a small, reviewed set of conditions. A timeout or temporary upstream failure may be retryable. An invalid request, policy refusal, malformed schema, or authentication error generally needs a different path. Do not retry every exception, and do not send the same prompt to three providers without recording that choice.&lt;/p&gt;

&lt;p&gt;Use an idempotency key for the surrounding job. &lt;a href="https://dev.to/blog/ai-production-background-jobs"&gt;Background jobs for AI features&lt;/a&gt; explains why a worker needs explicit state and safe retries; the same rule applies when a model fallback is inside that worker. Store the task ID, attempt number, provider, model, status, and validation result. If the worker restarts after the first provider returned a response, it should know whether to reuse, validate, or deliberately retry it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you preserve structured output across providers?
&lt;/h2&gt;

&lt;p&gt;Treat structured output as a validation contract, not as a formatting preference. The provider may offer a schema feature, but your application still needs to validate the returned value before using 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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zod&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;SupportLabel&lt;/span&gt; &lt;span class="o"&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;label&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;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;billing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bug&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;account&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;other&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
  &lt;span class="na"&gt;confidence&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;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseSupportLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&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;SupportLabel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&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;If your project does not use this validation library, use the validator already established in the repository. The invariant is what matters: parse, validate, and reject before a model result changes application state.&lt;/p&gt;

&lt;p&gt;Keep fixtures for every provider adapter. One fixture should be a valid result, one should be truncated, one should contain unexpected fields, and one should represent a provider error. Then run the same task set through each adapter and compare the normalized result, not only the raw text.&lt;/p&gt;

&lt;p&gt;For quality measurement, link each result to an evaluation case. &lt;a href="https://dev.to/blog/llm-evaluation-loop"&gt;The LLM evaluation loop&lt;/a&gt; covers the practical cycle: define cases, run a baseline, change one variable, inspect failures, and keep the evidence. Portability is useful only if switching providers does not erase your ability to compare behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should you observe and budget?
&lt;/h2&gt;

&lt;p&gt;Record provider, model, latency, input and output usage when returned, retry count, validation status, and a redacted task identifier. &lt;a href="https://dev.to/blog/llm-observability-guide"&gt;LLM observability for production apps&lt;/a&gt; describes why the trace should connect the request to retrieval, model calls, tool authorization, validation, and the final outcome.&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="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;AiTrace&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;taskId&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;taskName&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;provider&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;model&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;durationMs&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="nl"&gt;attempts&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="nl"&gt;validation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;passed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;skipped&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;inputTokens&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="nl"&gt;outputTokens&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="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not log prompts or outputs by default when they may contain private customer data. Sample deliberately, redact before storage, and set retention by task type. A provider switch is a data-flow change, so review where prompts travel and where responses are retained before changing the routing table.&lt;/p&gt;

&lt;p&gt;Your budget should include failed attempts. If fallback turns one request into two paid calls, the cost model must show that. Compare successful task rate, validated-result rate, latency, retry rate, and cost per accepted result. A cheaper token price is not a cost win if validation failures create manual work.&lt;/p&gt;

&lt;h2&gt;
  
  
  When is portability worth the extra code?
&lt;/h2&gt;

&lt;p&gt;Portability is worth paying for when you have a real reason to change or compare providers: regional availability, workload-specific quality, cost variation, data handling requirements, or a fallback policy. It is not worth building a ten-provider abstraction before one production task exists.&lt;/p&gt;

&lt;p&gt;Keep the first adapter small, put it behind the task boundary, and add a second provider only when a measured requirement justifies it. OTF’s &lt;a href="https://otf-kit.dev/templates" rel="noopener noreferrer"&gt;full-stack app templates&lt;/a&gt; are one starting point for builders who want owned application code plus AI-tool configuration before they add this boundary; the provider adapters and policy decisions remain yours.&lt;/p&gt;

&lt;p&gt;AI provider portability is a code-ownership decision as much as a vendor decision. Normalize the product contract, preserve differences behind adapters, validate every result, and log enough metadata to compare accepted outcomes. That gives you room to change providers without pretending their APIs or behavior are interchangeable.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developers.openai.com/api/reference/resources/responses" rel="noopener noreferrer"&gt;Responses API reference — OpenAI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://platform.claude.com/docs/en/api/messages" rel="noopener noreferrer"&gt;Messages API — Anthropic&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://json-schema.org/understanding-json-schema" rel="noopener noreferrer"&gt;AI provider portability implementation reference — JSON Schema&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://otf-kit.dev/blog/ai-provider-portability" rel="noopener noreferrer"&gt;otf-kit.dev&lt;/a&gt; — full-stack app templates for web and mobile. &lt;a href="https://otf-kit.dev/templates" rel="noopener noreferrer"&gt;See the templates →&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>LLM observability for production apps: what to trace, measure, and fix</title>
      <dc:creator>Dave Kurian</dc:creator>
      <pubDate>Mon, 31 Aug 2026 18:26:12 +0000</pubDate>
      <link>https://dev.to/davekurian/llm-observability-for-production-apps-what-to-trace-measure-and-fix-3lcg</link>
      <guid>https://dev.to/davekurian/llm-observability-for-production-apps-what-to-trace-measure-and-fix-3lcg</guid>
      <description>&lt;h2&gt;
  
  
  LLM observability starts with the whole request
&lt;/h2&gt;

&lt;p&gt;LLM observability is the practice of connecting a user request to the model calls, retrieval steps, tool calls, latency, cost, and final outcome that followed. In production, logging the generated text alone is not enough. When an answer is slow, expensive, unsafe, or simply wrong, builders need to see which step caused the failure.&lt;/p&gt;

&lt;p&gt;The practical approach is to create one trace for the application request and attach a span to every meaningful operation: prompt construction, retrieval, model invocation, tool authorization, tool execution, validation, and response delivery. Record measurements that help you make a decision, but do not automatically store sensitive prompts or user data.&lt;/p&gt;

&lt;p&gt;This guide focuses on a small observability contract that works across model providers and can be implemented before adopting a specialized vendor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define the signals before the dashboard
&lt;/h2&gt;

&lt;p&gt;Start with questions, not charts. A useful production system should answer questions such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which workflow is slow?&lt;/li&gt;
&lt;li&gt;Is latency coming from retrieval, the model, a tool, or a retry?&lt;/li&gt;
&lt;li&gt;Which model and configuration produced the response?&lt;/li&gt;
&lt;li&gt;How often are outputs rejected by validation?&lt;/li&gt;
&lt;li&gt;Which requests exceeded their cost or latency budget?&lt;/li&gt;
&lt;li&gt;Did a tool call fail because of authorization, invalid input, or an upstream outage?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Group the signals into four categories:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Traces&lt;/strong&gt; show the path of one request through the system.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metrics&lt;/strong&gt; show rates and distributions across many requests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logs&lt;/strong&gt; preserve structured events and error details.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evaluations&lt;/strong&gt; measure whether the output was useful, correct, safe, or complete.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;OpenTelemetry provides a vendor-neutral framework for capturing traces and metrics, and its context-propagation model is designed to correlate signals across service boundaries. Its GenAI semantic conventions have moved to a dedicated repository, so check the current conventions before fixing your own attribute names. Sources: &lt;a href="https://opentelemetry.io/" rel="noopener noreferrer"&gt;OpenTelemetry&lt;/a&gt; and &lt;a href="https://opentelemetry.io/docs/concepts/context-propagation/" rel="noopener noreferrer"&gt;OpenTelemetry context propagation&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the trace structure boring
&lt;/h2&gt;

&lt;p&gt;A consistent trace shape is more valuable than a clever one. For one user request, use a structure similar to this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;request
├── prompt.build
├── retrieval
│   ├── search
│   └── rerank
├── model.call
├── tool.authorization
├── tool.execute
├── output.validate
└── response.write
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not every request uses every span. The important rule is that each span has a stable name, a start and end time, a status, and a correlation ID. Use the same names for the same operations across workflows so that a dashboard can compare them.&lt;/p&gt;

&lt;p&gt;For a model span, record the provider, model identifier, request mode, input and output token counts when available, time to first token when relevant, total duration, retry count, and finish category. Keep the raw prompt and response out of general-purpose telemetry by default. If you need samples for debugging, store them in a controlled system with access restrictions and an explicit retention policy.&lt;/p&gt;

&lt;p&gt;For retrieval spans, record the index or collection identifier, query type, number of candidates, selected document count, retrieval duration, and whether the context was empty. Avoid placing full document contents in spans. A document reference, content hash, or redacted excerpt is usually enough to reproduce the path without copying private data into every telemetry backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure the failures users actually feel
&lt;/h2&gt;

&lt;p&gt;Average latency hides the tail. Track p50, p95, and p99 latency separately for the whole request and for major spans. A workflow can have a reasonable average while a meaningful group of users waits through a slow retrieval call or repeated model retries.&lt;/p&gt;

&lt;p&gt;Measure the following rates by workflow and model configuration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Request success and failure&lt;/li&gt;
&lt;li&gt;Timeout and cancellation&lt;/li&gt;
&lt;li&gt;Retry frequency&lt;/li&gt;
&lt;li&gt;Output-validation rejection&lt;/li&gt;
&lt;li&gt;Tool-authorization rejection&lt;/li&gt;
&lt;li&gt;Empty retrieval context&lt;/li&gt;
&lt;li&gt;Fallback-model usage&lt;/li&gt;
&lt;li&gt;Human escalation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For cost, track estimated input and output usage by workflow, tenant, and model. Treat estimates as estimates when provider billing data arrives later or pricing changes. A budget alert should be based on a documented calculation, not a dashboard label that looks precise.&lt;/p&gt;

&lt;p&gt;For quality, connect an evaluation result to the same request or trace identifier. A response that completed in 800 milliseconds is not necessarily successful if it cited the wrong record or omitted a required field. The evaluation loop should be separate from the runtime trace, but linked to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use structured events for decisions
&lt;/h2&gt;

&lt;p&gt;A log line such as &lt;code&gt;agent failed&lt;/code&gt; is almost useless. Emit structured events with a stable event name and a small set of safe fields.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"llm_request_finished"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"trace_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"trace-identifier"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"workflow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"support-answer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"provider-model-id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"validated"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"latency_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;842&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"input_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1240&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"output_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;318&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"retry_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tool_calls"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The identifier in this example is a correlation value, not a secret. Never put API keys, authorization headers, access tokens, or unredacted personal data into logs. Apply the same discipline to exception messages: upstream errors often contain request payloads or URLs that were not meant for a general log destination.&lt;/p&gt;

&lt;p&gt;Use explicit status values such as &lt;code&gt;completed&lt;/code&gt;, &lt;code&gt;timed_out&lt;/code&gt;, &lt;code&gt;blocked_by_policy&lt;/code&gt;, &lt;code&gt;invalid_output&lt;/code&gt;, and &lt;code&gt;upstream_error&lt;/code&gt;. These categories let operators separate product failures from infrastructure failures and policy decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debug by comparing traces
&lt;/h2&gt;

&lt;p&gt;When an incident arrives, compare a successful trace with a failing trace from the same workflow. Look for the first meaningful divergence:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Did retrieval return fewer or different documents?&lt;/li&gt;
&lt;li&gt;Did the prompt exceed the intended context budget?&lt;/li&gt;
&lt;li&gt;Did the model configuration change?&lt;/li&gt;
&lt;li&gt;Did a tool authorization check reject a valid action?&lt;/li&gt;
&lt;li&gt;Did a retry reuse a non-idempotent operation?&lt;/li&gt;
&lt;li&gt;Did validation reject a response that the model considered complete?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not begin with the final answer and guess backward. Follow the trace from the request boundary. This avoids blaming the model for a timeout caused by a slow database or blaming retrieval for a schema mismatch introduced after generation.&lt;/p&gt;

&lt;p&gt;Add a trace link to operational errors, evaluation failures, and support tickets where appropriate. That creates a short path from “the answer was wrong” to the exact request path that produced it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Protect telemetry from becoming a new data leak
&lt;/h2&gt;

&lt;p&gt;Observability data can be more sensitive than application logs because it may contain user prompts, retrieved documents, model outputs, and tool arguments together. Define a telemetry policy before enabling verbose capture.&lt;/p&gt;

&lt;p&gt;Use field allowlists rather than trying to redact everything after collection. Hash or classify identifiers when operators do not need the original value. Restrict access by tenant and role. Set retention periods for raw samples, traces, metrics, and aggregate reports separately.&lt;/p&gt;

&lt;p&gt;Sample routine successful traces more aggressively than failures, but keep enough metadata to understand traffic changes. Never sample away all blocked, timed-out, or validation-failed traces; those are the traces most useful during an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  An implementation sequence for builders
&lt;/h2&gt;

&lt;p&gt;A safe rollout can happen in four passes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add a request ID and trace context at the application boundary.&lt;/li&gt;
&lt;li&gt;Instrument model, retrieval, tool, validation, and response spans.&lt;/li&gt;
&lt;li&gt;Add a small metric set for latency, failures, retries, tokens, and policy blocks.&lt;/li&gt;
&lt;li&gt;Link offline evaluations and support reports back to trace IDs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Only after this foundation is stable should you add expensive payload capture or complex dashboards. Start with one production workflow and verify that a deliberately induced timeout, validation failure, and tool rejection are all visible and distinguishable.&lt;/p&gt;

&lt;p&gt;For related production patterns, compare this guide with &lt;a href="https://otf-kit.dev/blog/ai-production-background-jobs" rel="noopener noreferrer"&gt;background jobs for AI features&lt;/a&gt;, the &lt;a href="https://otf-kit.dev/blog/llm-evaluation-loop" rel="noopener noreferrer"&gt;LLM evaluation loop&lt;/a&gt;, and &lt;a href="https://otf-kit.dev/blog/safe-ai-agent-tool-permissions" rel="noopener noreferrer"&gt;safe AI agent tool permissions&lt;/a&gt;. The broader lesson is the same: reliability comes from explicit boundaries, measurable outcomes, and recoverable operations. OTF’s &lt;a href="https://otf-kit.dev/templates" rel="noopener noreferrer"&gt;templates&lt;/a&gt; can be a starting point when you want to turn those production conventions into an owned application structure.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://opentelemetry.io/" rel="noopener noreferrer"&gt;OpenTelemetry&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://opentelemetry.io/docs/concepts/context-propagation/" rel="noopener noreferrer"&gt;OpenTelemetry context propagation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/open-telemetry/semantic-conventions-genai" rel="noopener noreferrer"&gt;OpenTelemetry GenAI semantic conventions&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>How to design safe tool permissions for AI agents in production apps</title>
      <dc:creator>Dave Kurian</dc:creator>
      <pubDate>Mon, 31 Aug 2026 13:42:06 +0000</pubDate>
      <link>https://dev.to/davekurian/how-to-design-safe-tool-permissions-for-ai-agents-in-production-apps-3mj9</link>
      <guid>https://dev.to/davekurian/how-to-design-safe-tool-permissions-for-ai-agents-in-production-apps-3mj9</guid>
      <description>&lt;h2&gt;
  
  
  The model is not the authority
&lt;/h2&gt;

&lt;p&gt;An AI agent can choose a tool, fill in arguments, and explain what it intends to do. None of those things make it authorized to perform the action.&lt;/p&gt;

&lt;p&gt;That distinction matters as soon as an agent can access customer records, create invoices, update a project, send a message, or call an external service. A helpful demo often gives the model a broad function such as &lt;code&gt;runAction&lt;/code&gt; and trusts the model to decide when it is appropriate. A production application cannot make that the security boundary.&lt;/p&gt;

&lt;p&gt;Treat the model as an untrusted decision-maker inside a user session. The application must authenticate the user, check authorization, validate the tool arguments, enforce limits, and record the outcome independently of the model’s reasoning.&lt;/p&gt;

&lt;p&gt;The goal is not to make the model perfectly obedient. The goal is to make the application safe when the model is mistaken, manipulated, or unavailable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with a tool inventory
&lt;/h2&gt;

&lt;p&gt;Before writing prompts, list every action the agent can take. Give each tool a plain-language purpose, required inputs, affected resources, and worst-case consequence.&lt;/p&gt;

&lt;p&gt;A read-only search tool may expose private data if its query ignores tenant boundaries. A calendar tool may create an unwanted commitment. A “send” tool may contact hundreds of people if the recipient list is not constrained. A database update may be reversible in theory but damaging in practice.&lt;/p&gt;

&lt;p&gt;Classify tools into four levels:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Read-only access to information&lt;/li&gt;
&lt;li&gt;Reversible changes to a user-owned resource&lt;/li&gt;
&lt;li&gt;External communication or financial action&lt;/li&gt;
&lt;li&gt;Irreversible or high-impact operations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The classification should determine the controls. Do not use the same approval path for searching documentation and deleting an account.&lt;/p&gt;

&lt;p&gt;Keep the inventory close to the implementation. If the tool registry changes but the security review does not, the review is already stale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give every tool the smallest possible permission
&lt;/h2&gt;

&lt;p&gt;Least privilege is more reliable than a longer system prompt.&lt;/p&gt;

&lt;p&gt;Instead of exposing a general-purpose function that accepts an arbitrary URL, record ID, or SQL expression, create narrow operations with constrained inputs. Prefer &lt;code&gt;get_invoice(invoiceId)&lt;/code&gt; over &lt;code&gt;query_database(sql)&lt;/code&gt;. Prefer &lt;code&gt;draft_email(recipientId, templateId)&lt;/code&gt; over &lt;code&gt;send_email(to, subject, body)&lt;/code&gt; when the product does not need unrestricted sending.&lt;/p&gt;

&lt;p&gt;A narrow tool is easier to test, authorize, rate-limit, and explain to the user. It also reduces the damage caused by prompt injection in retrieved content.&lt;/p&gt;

&lt;p&gt;Keep tenant and user identity outside model-controlled arguments whenever possible. The server should derive the current user and tenant from the authenticated session. If the model supplies an identifier, verify that the resource belongs to the same authorization scope before reading or changing it.&lt;/p&gt;

&lt;p&gt;Do not give the model secrets. A tool implementation can use a server-side credential without placing that credential in the prompt, conversation history, or model-visible result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate intent from authorization
&lt;/h2&gt;

&lt;p&gt;The agent can propose an action. Your application decides whether that action is permitted.&lt;/p&gt;

&lt;p&gt;A useful flow has distinct stages:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The user asks for an outcome.&lt;/li&gt;
&lt;li&gt;The model proposes a tool and structured arguments.&lt;/li&gt;
&lt;li&gt;The server validates the schema and resource ownership.&lt;/li&gt;
&lt;li&gt;The authorization layer checks the user, role, tenant, and policy.&lt;/li&gt;
&lt;li&gt;The application requests confirmation if the action is sensitive.&lt;/li&gt;
&lt;li&gt;The tool executes with bounded permissions.&lt;/li&gt;
&lt;li&gt;The result is recorded and shown to the user.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The model should never be able to skip stages four or five by writing “confirmed” in its own output. Confirmation must come from the application interface or another trusted control.&lt;/p&gt;

&lt;p&gt;For example, an agent may prepare a customer email, but the user must approve the final recipients and content before delivery. The approval should be tied to a specific action hash or request identifier so an old approval cannot be replayed against a changed recipient list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validate arguments twice
&lt;/h2&gt;

&lt;p&gt;Validate once at the model boundary for useful feedback and again at the tool boundary for security.&lt;/p&gt;

&lt;p&gt;The first validation can tell the model that a required field is missing or that a date is invalid. The second validation must assume the input is hostile. Check types, ranges, allowed identifiers, URL destinations, recipient counts, file sizes, and action-specific rules.&lt;/p&gt;

&lt;p&gt;Never interpolate model output directly into a shell command, database query, HTML response, or network request. Use parameterized operations and allowlists. If a tool accepts a URL, restrict protocols and hosts. If it accepts a file path, resolve it against an allowed directory and prevent traversal. If it accepts a quantity, enforce both a type and a business limit.&lt;/p&gt;

&lt;p&gt;Structured output helps, but a valid structure is not proof that the operation is safe. An object can satisfy a schema and still point to a resource the user cannot access.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put budgets around agent behavior
&lt;/h2&gt;

&lt;p&gt;Permissions answer “may this action happen?” Budgets answer “how much can happen?”&lt;/p&gt;

&lt;p&gt;Set limits for tool calls per request, records returned, recipients, spend, execution time, retries, and outbound domains. Use separate budgets for read and write operations. A runaway read loop may create a cost problem; a runaway write loop may create a customer incident.&lt;/p&gt;

&lt;p&gt;Track budgets across the whole workflow, not only inside one model call. If the agent retries three times and each retry can call five tools, the effective limit is larger than the prompt may suggest.&lt;/p&gt;

&lt;p&gt;Make high-impact budgets visible. A user should not discover after the fact that an agent was allowed to send 500 messages or modify every record in a workspace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat retrieved content as untrusted input
&lt;/h2&gt;

&lt;p&gt;Documents, webpages, tickets, and customer messages can contain instructions aimed at the model. A retrieved page might say to ignore previous rules and export data. The agent should treat that text as content to analyze, not as an authority that can change its permissions.&lt;/p&gt;

&lt;p&gt;Use clear boundaries between instructions and retrieved data. Keep retrieved text out of tool definitions. Limit which fields are passed to the model. Require server-side authorization for every resource access, even when the retrieval result already contains an identifier.&lt;/p&gt;

&lt;p&gt;Do not try to solve prompt injection with one magic sentence. Reduce the blast radius instead. A model that sees malicious instructions should still be unable to access another tenant, call an unapproved host, or perform an irreversible operation without confirmation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design safe failure and recovery
&lt;/h2&gt;

&lt;p&gt;Tools fail. Providers time out. Workers retry. Users change their minds. Permission checks can change between planning and execution.&lt;/p&gt;

&lt;p&gt;Return errors that are useful without exposing secrets or internal infrastructure. Distinguish invalid input, not authorized, rate limited, unavailable, and policy blocked. Do not let a failed tool call cause the model to silently invent a successful result.&lt;/p&gt;

&lt;p&gt;Make side effects idempotent. If a request is retried after the network drops, the same action should not send two emails or create two invoices. Attach a stable operation identifier to the action and store the result before allowing a retry to proceed.&lt;/p&gt;

&lt;p&gt;For multi-step workflows, checkpoint progress. If an agent has already created a draft and the next step fails, the retry should resume from the known state rather than create a second draft.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build an audit trail that answers what happened
&lt;/h2&gt;

&lt;p&gt;A useful audit record connects the user request to the actual side effect. Record the request ID, authenticated subject, tenant, tool name, validated argument shape, authorization decision, confirmation state, start and end time, result category, and operation identifier.&lt;/p&gt;

&lt;p&gt;Avoid storing secrets and unnecessary personal content. Redact sensitive fields or store references to controlled records instead of copying full prompts into every log.&lt;/p&gt;

&lt;p&gt;The audit trail should make these questions answerable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Who requested the action?&lt;/li&gt;
&lt;li&gt;Which policy allowed or blocked it?&lt;/li&gt;
&lt;li&gt;What resource was affected?&lt;/li&gt;
&lt;li&gt;Did a human confirm it?&lt;/li&gt;
&lt;li&gt;Was the operation retried?&lt;/li&gt;
&lt;li&gt;What was the final outcome?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without that evidence, a production incident becomes an argument about what the model probably intended.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the permission boundary, not only the happy path
&lt;/h2&gt;

&lt;p&gt;Add abuse cases to the evaluation set. Ask whether a user can request another tenant’s record by changing an ID. Put an instruction in a retrieved document. Try an unapproved URL. Replay an old confirmation. Send an empty or extremely large recipient list. Retry after a simulated timeout. Remove the user’s role between planning and execution.&lt;/p&gt;

&lt;p&gt;Test every tool with a user who can see the interface but should not have the underlying permission. Test a tool with malformed structured output. Test a provider outage and a worker restart.&lt;/p&gt;

&lt;p&gt;The strongest result is not “the model followed the prompt.” It is “the application remained safe when the model did not.”&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical release checklist
&lt;/h2&gt;

&lt;p&gt;Before shipping an agent tool, confirm that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The tool has one narrow purpose.&lt;/li&gt;
&lt;li&gt;User and tenant scope come from the server session.&lt;/li&gt;
&lt;li&gt;Arguments are validated at both the model and tool boundaries.&lt;/li&gt;
&lt;li&gt;Resource ownership is checked immediately before execution.&lt;/li&gt;
&lt;li&gt;Sensitive actions require trusted confirmation.&lt;/li&gt;
&lt;li&gt;Limits exist for calls, records, spend, recipients, and retries.&lt;/li&gt;
&lt;li&gt;External writes are idempotent.&lt;/li&gt;
&lt;li&gt;Retrieved content cannot grant permissions.&lt;/li&gt;
&lt;li&gt;Secrets never enter model context or logs.&lt;/li&gt;
&lt;li&gt;Audit records explain the authorization and outcome.&lt;/li&gt;
&lt;li&gt;Abuse cases are part of the regression suite.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This design takes more thought than handing an agent a large toolbox. It also gives builders something far more valuable than a convincing demo: a system that can explain what it is allowed to do, stop when it reaches a boundary, and recover without surprising the user.&lt;/p&gt;

&lt;p&gt;If you are building with OTF, use this permission model as part of the production foundation around any AI feature. The model can be replaced. The authorization boundary should remain clear.&lt;/p&gt;

&lt;h2&gt;
  
  
  Source
&lt;/h2&gt;

&lt;p&gt;OWASP Top 10 for Large Language Model Applications: &lt;a href="https://owasp.org/www-project-top-10-for-large-language-model-applications/" rel="noopener noreferrer"&gt;https://owasp.org/www-project-top-10-for-large-language-model-applications/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>security</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>A practical LLM evaluation loop for AI features that need to ship</title>
      <dc:creator>Dave Kurian</dc:creator>
      <pubDate>Sun, 30 Aug 2026 17:57:53 +0000</pubDate>
      <link>https://dev.to/davekurian/a-practical-llm-evaluation-loop-for-ai-features-that-need-to-ship-426k</link>
      <guid>https://dev.to/davekurian/a-practical-llm-evaluation-loop-for-ai-features-that-need-to-ship-426k</guid>
      <description>&lt;h2&gt;
  
  
  Prompt testing is not an evaluation strategy
&lt;/h2&gt;

&lt;p&gt;An AI feature can look excellent in a demo and still fail the first week of real use. A user asks a question in a different way, a retrieved document is incomplete, a tool returns an unexpected shape, or a model update changes the tone of the answer. The team then opens the prompt, changes a sentence, and tests three examples by hand.&lt;/p&gt;

&lt;p&gt;That process feels productive because the output changes immediately. It is not a reliable way to know whether the product improved.&lt;/p&gt;

&lt;p&gt;A production AI feature needs an evaluation loop. The loop should make a change observable, repeatable, and reversible. You need a set of representative inputs, a definition of acceptable behavior, automated checks for obvious failures, and a way to learn from what users do after the answer appears.&lt;/p&gt;

&lt;p&gt;The useful question is not “does this prompt work?” It is “does this version perform better on the jobs our users actually need done?”&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with a small, owned dataset
&lt;/h2&gt;

&lt;p&gt;Do not wait for a perfect benchmark. Start with the examples your product already creates.&lt;/p&gt;

&lt;p&gt;Collect successful requests, failed requests, user corrections, support tickets, abandoned flows, and inputs that required a human handoff. Remove personal information and secrets. Then label each example with the behavior that matters. A support answer might need to cite the correct policy, refuse an unsupported request, and avoid inventing a deadline. A code-generation feature might need to preserve an API contract, produce valid syntax, and avoid changing a protected file.&lt;/p&gt;

&lt;p&gt;Fifty carefully chosen examples can reveal more than a thousand random prompts. Divide them into a development set and a holdout set. Use the development set while improving the feature. Keep the holdout set hidden from day-to-day prompt editing so it can tell you whether the change generalizes.&lt;/p&gt;

&lt;p&gt;Version the dataset. Add a short reason whenever an example changes. If a case was added because a user found a serious failure, keep that context. Your evaluation set is not disposable test data; it is the product’s growing memory of what “good” means.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define behavior before choosing a grader
&lt;/h2&gt;

&lt;p&gt;A single score such as “quality: 8.4” is not enough. Define the dimensions that make an output useful or dangerous.&lt;/p&gt;

&lt;p&gt;For an AI assistant that answers product questions, useful dimensions might be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Factual accuracy&lt;/li&gt;
&lt;li&gt;Completeness&lt;/li&gt;
&lt;li&gt;Citation or evidence use&lt;/li&gt;
&lt;li&gt;Appropriate refusal&lt;/li&gt;
&lt;li&gt;Clear next step&lt;/li&gt;
&lt;li&gt;Tone and readability&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For an agent that can call tools, add:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Correct tool selection&lt;/li&gt;
&lt;li&gt;Valid arguments&lt;/li&gt;
&lt;li&gt;Authorization compliance&lt;/li&gt;
&lt;li&gt;Side-effect safety&lt;/li&gt;
&lt;li&gt;Recovery after a failed tool call&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Write a short rubric for each dimension. A good rubric tells the grader what a passing answer must contain and what counts as a failure. It should also identify severe failures that cannot be averaged away. Leaking another tenant’s data is not merely a low score; it is a release blocker.&lt;/p&gt;

&lt;p&gt;This is where many evaluation projects go wrong. The team asks a grader whether an answer is “good” and then trusts the number. A useful grader is closer to a reviewer with a checklist: it can explain which requirement was missed and assign a result that maps to a product decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Combine deterministic and judgment-based checks
&lt;/h2&gt;

&lt;p&gt;Use the cheapest reliable check first.&lt;/p&gt;

&lt;p&gt;Deterministic checks are ideal for structure and safety. Validate JSON against a schema. Confirm required fields exist. Reject unknown tool names. Check that a citation field is present when the workflow requires evidence. Enforce maximum length, latency, token, and tool-call budgets. Verify that an action requiring confirmation has not executed without confirmation.&lt;/p&gt;

&lt;p&gt;These checks should run on every change because they are fast and predictable.&lt;/p&gt;

&lt;p&gt;Reference-based checks compare the result with known facts or required entities. Exact string matching is often too brittle, especially when several answers can be correct. Instead, check whether required facts appear, forbidden claims are absent, and important names, dates, or identifiers are correct.&lt;/p&gt;

&lt;p&gt;Judgment-based checks are useful for qualities such as helpfulness, completeness, and tone. They can be performed by a human reviewer or a separate model with a strict rubric. Keep the evaluator separate from the system being tested where possible. Record the rubric version and evaluator model alongside the result.&lt;/p&gt;

&lt;p&gt;Never let a judgment score replace a safety check. A fluent answer can still contain a forbidden claim, expose private data, or trigger an unauthorized action.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evaluate the whole workflow
&lt;/h2&gt;

&lt;p&gt;The model response is only one part of the user experience.&lt;/p&gt;

&lt;p&gt;Suppose a retrieval assistant gives a wrong answer. The root cause might be the model, but it might also be that the correct document was never retrieved, the tenant filter was missing, the context was truncated, or an old result was displayed in the interface. If you evaluate only the final paragraph, you cannot tell which subsystem needs attention.&lt;/p&gt;

&lt;p&gt;Capture evaluation data for the workflow stages that matter:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The user input and normalized task&lt;/li&gt;
&lt;li&gt;Retrieval queries, filters, and result identifiers&lt;/li&gt;
&lt;li&gt;Context assembled for the model&lt;/li&gt;
&lt;li&gt;Model output and structured fields&lt;/li&gt;
&lt;li&gt;Tool calls and authorization decisions&lt;/li&gt;
&lt;li&gt;Validation and retry results&lt;/li&gt;
&lt;li&gt;The final response shown to the user&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Keep sensitive content out of logs unless your data policy explicitly allows it. You can often retain redacted samples, hashes, identifiers, and structured outcomes instead of every raw prompt and document.&lt;/p&gt;

&lt;p&gt;For asynchronous workflows, evaluate recovery too. A job that succeeds on the first attempt but duplicates an email after a retry is not reliable. Include timeouts, provider errors, cancellation, partial completion, and stale status in the test set.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turn every production failure into a regression case
&lt;/h2&gt;

&lt;p&gt;The most valuable evaluation case is often the one that just failed in production.&lt;/p&gt;

&lt;p&gt;When a user corrects an answer, save a sanitized version of the input, the bad behavior, the expected behavior, and the condition that caused the failure. Add it to the development set immediately. Move it to the holdout set after the fix has been tested. That prevents the team from celebrating a local improvement while silently overfitting to the same examples.&lt;/p&gt;

&lt;p&gt;Track failure categories, not only pass rates. You want to know whether prompt injection attempts are increasing, whether retrieval failures cluster around one document type, whether one customer segment sees more refusals, or whether a model change increases tool-call errors.&lt;/p&gt;

&lt;p&gt;Slice results by language, workflow version, customer tier, input length, document type, and task difficulty. Aggregate numbers hide important regressions. A new version can improve the average while breaking the exact high-value workflow your best customers use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the evaluation a release gate
&lt;/h2&gt;

&lt;p&gt;An evaluation is useful when it changes what you do.&lt;/p&gt;

&lt;p&gt;Set a small release policy. For example, a change may ship only when deterministic checks pass, no critical safety case regresses, the holdout score does not fall below its threshold, and the latency or cost budget remains acceptable. The exact thresholds should reflect the product, but the decision must be explicit before the result arrives.&lt;/p&gt;

&lt;p&gt;Store each run with the prompt version, model identifier, retrieval configuration, tool definitions, dataset version, grader version, and timestamp. Without this metadata, a result cannot explain what changed.&lt;/p&gt;

&lt;p&gt;A simple runner can make the contract visible:&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="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;EvalCase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;input&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;requiredClaims&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;forbiddenClaims&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;expectedTools&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;EvalResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;passed&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="nl"&gt;failures&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;latencyMs&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="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;evaluateCase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;testCase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;EvalCase&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;EvalResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&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;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;runFeature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;testCase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;failures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;checkOutput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;testCase&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;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;testCase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;passed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;failures&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="nx"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;latencyMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The implementation will evolve, but the principle stays stable: every result should say what was tested, what failed, and whether the change is safe to release.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use real user outcomes as the final signal
&lt;/h2&gt;

&lt;p&gt;Offline evaluations are necessary, but they are not the finish line. Watch what users do after the response.&lt;/p&gt;

&lt;p&gt;Do they accept it, edit it, retry, abandon the workflow, open a support request, or ask for a human? For generated code, does the patch pass tests and survive review? For a support answer, does it resolve the case without a follow-up correction?&lt;/p&gt;

&lt;p&gt;These signals are not perfect labels. A user may accept a bad answer or reject a good one for reasons unrelated to quality. Still, they reveal cases your curated dataset missed. Sample them responsibly, remove sensitive data, and feed the important failures back into the evaluation set.&lt;/p&gt;

&lt;p&gt;That creates a durable loop: collect representative cases, define acceptable behavior, run cheap checks, review meaningful judgments, ship only against explicit thresholds, observe real outcomes, and turn failures into new tests.&lt;/p&gt;

&lt;p&gt;The result is a better way to build with AI. You can change the prompt, model, retrieval strategy, or tool workflow without relying on instinct alone. You can explain why a version shipped. Most importantly, you can catch a regression before a user has to become your evaluator.&lt;/p&gt;

&lt;p&gt;OTF is built for teams that want to move from an AI-shaped prototype to a product with clear conventions and a real release path. Keep the evaluation loop close to the code, make the pass criteria visible, and let every production lesson improve the next build.&lt;/p&gt;

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

&lt;p&gt;OpenAI Evals — framework and registry for evaluating LLMs and LLM systems: &lt;a href="https://github.com/openai/evals" rel="noopener noreferrer"&gt;https://github.com/openai/evals&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>An AI app security checklist for builders moving from demo to production</title>
      <dc:creator>Dave Kurian</dc:creator>
      <pubDate>Sun, 30 Aug 2026 11:19:57 +0000</pubDate>
      <link>https://dev.to/davekurian/an-ai-app-security-checklist-for-builders-moving-from-demo-to-production-2f46</link>
      <guid>https://dev.to/davekurian/an-ai-app-security-checklist-for-builders-moving-from-demo-to-production-2f46</guid>
      <description>&lt;p&gt;A production AI app needs more than a safe-looking system prompt. Before you ship, you need a boundary around every input, model call, tool invocation, database write, and user-visible output. This AI app security checklist gives you that boundary in concrete terms.&lt;/p&gt;

&lt;p&gt;The short version: authenticate the caller, authorize each resource, treat retrieved text as untrusted data, validate model output before using it, keep tools narrow, store secrets outside code, cap spend and rate, and log enough context to investigate a bad result without logging private data by accident. These controls matter whether your first version was written with Cursor, Claude Code, or another AI coding tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the threat model, not the prompt
&lt;/h2&gt;

&lt;p&gt;Write down the path from user input to side effect. For a support assistant, it might be:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A signed-in user submits a question.&lt;/li&gt;
&lt;li&gt;The server loads documents for that user's workspace.&lt;/li&gt;
&lt;li&gt;The model drafts an answer.&lt;/li&gt;
&lt;li&gt;A tool fetches an account record.&lt;/li&gt;
&lt;li&gt;The server saves the answer.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now mark every boundary. Which values come from the browser? Which come from a document? Which can cause a write? Which identity is attached to the request? If the answer is “the model decides,” you have found a security gap, not an architecture.&lt;/p&gt;

&lt;p&gt;OWASP's current GenAI LLM Top 10 lists prompt injection, insecure output handling, sensitive information disclosure, excessive agency, and denial of service among the risks application teams need to address. Use the &lt;a href="https://genai.owasp.org/resource/owasp-genai-llm-top-10-2026/" rel="noopener noreferrer"&gt;OWASP GenAI LLM Top 10&lt;/a&gt; as a threat checklist, then map each item to a server-side control.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authenticate and authorize on the server
&lt;/h2&gt;

&lt;p&gt;A model should never be the source of truth for identity. The request handler must derive the user and workspace from a verified session, then check access to every record before it enters the prompt or leaves the database.&lt;/p&gt;

&lt;p&gt;Do not accept &lt;code&gt;userId&lt;/code&gt;, &lt;code&gt;workspaceId&lt;/code&gt;, or &lt;code&gt;role&lt;/code&gt; from the request body and trust it. A browser can send any JSON it wants. The same rule applies to tool arguments generated by a model: parse them, validate them, and run authorization against the authenticated principal.&lt;/p&gt;

&lt;p&gt;A small policy function makes the boundary visible:&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="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Principal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="na"&gt;workspaceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;member&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;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ToolRequest&lt;/span&gt; &lt;span class="o"&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;get_invoice&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;create_ticket&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="nx"&gt;invoiceId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;subject&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;authorizeTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Principal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ToolRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&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="s2"&gt;get_invoice&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invoiceId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invoiceId is required&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;invoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findFirst&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invoiceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;workspaceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workspaceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;select&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invoice not found&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="nx"&gt;invoice&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;request&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="s2"&gt;create_ticket&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;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;Not allowed&lt;/span&gt;&lt;span class="dl"&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subject&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;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;Invalid subject&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="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;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;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;Unknown tool&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The database query includes the workspace boundary. That is more useful than checking permissions in a prompt or relying on a hidden convention in generated code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat prompts, documents, and model output as data
&lt;/h2&gt;

&lt;p&gt;Prompt injection is not limited to a malicious user typing “ignore previous instructions.” It can arrive through a web page, uploaded file, ticket, repository issue, or customer message. OWASP's &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/LLM_Prompt_Injection_Prevention_Cheat_Sheet.html" rel="noopener noreferrer"&gt;prompt injection prevention guidance&lt;/a&gt; describes direct and indirect attacks, including instructions hidden in external content.&lt;/p&gt;

&lt;p&gt;Separate instructions from data in your application design. Put untrusted content in a clearly marked field, tell the model to summarize it rather than obey it, and assume that wording alone will not stop every attack. More important, do not give the model a tool whose permission exceeds the user's permission.&lt;/p&gt;

&lt;p&gt;The output needs a boundary too. If the model returns JSON, parse it with a schema. If it returns Markdown, render it with an escaping policy. If it returns an email address, URL, SQL fragment, shell command, or database identifier, validate that value for its specific use before passing it onward. “The model usually returns the right shape” is not validation.&lt;/p&gt;

&lt;p&gt;For a structured response, the safe sequence is:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate&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;Return a support classification.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userText&lt;/span&gt;&lt;span class="p"&gt;,&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;parsed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Model returned invalid JSON&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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;classificationSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ticketId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;workspaceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workspaceId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The schema should constrain enums, lengths, numeric ranges, and optional fields. Keep the write conditional on the same tenant scope used during authorization.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give tools the smallest possible permissions
&lt;/h2&gt;

&lt;p&gt;Start with read-only tools. Add writes only when you can name the user action, authorization rule, validation rule, and rollback behavior. A “manage account” tool is too broad; &lt;code&gt;get_invoice&lt;/code&gt; with one workspace-scoped identifier is easier to inspect.&lt;/p&gt;

&lt;p&gt;Require confirmation for consequential actions such as sending messages, changing billing details, deleting records, or publishing content. A model can prepare an action, but the user or a separate server-side policy should approve it. Record who approved it, what arguments were approved, and whether the final arguments changed before execution.&lt;/p&gt;

&lt;p&gt;Also set limits around loops. A tool-using agent should have a maximum number of steps, a maximum input size, a request timeout, and a budget for model calls. Return a controlled failure when a limit is reached. Do not let a retry loop turn a malformed request into a large bill.&lt;/p&gt;

&lt;p&gt;If the feature runs for longer than a request, pair these controls with a queue, stable job identity, and checkpoints. The &lt;a href="https://dev.to/blog/ai-production-background-jobs"&gt;background-jobs guide&lt;/a&gt; covers why retries and side effects need idempotency once an AI workflow crosses process boundaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Protect secrets and personal data
&lt;/h2&gt;

&lt;p&gt;Keep provider keys on the server and load them through the deployment's secret store. Never put them in browser bundles, prompts, screenshots, test fixtures, or exception messages. Review logs as if a customer will read them: redact tokens, authorization headers, passwords, full payment details, and unnecessary personal data.&lt;/p&gt;

&lt;p&gt;Use separate credentials for development, staging, and production where the provider supports it. Restrict production access to the smallest group that needs it. Add spend alerts and hard limits at the provider level, then enforce per-user and per-workspace limits in your own application. Provider controls catch account-wide drift; application controls stop one tenant from consuming everyone else's budget.&lt;/p&gt;

&lt;p&gt;Set retention deliberately. If you store prompts and responses for debugging, define how long they remain, who can access them, and how a user can request deletion. Hash or truncate identifiers in analytics where the full value is not needed. Security is not only preventing remote access; it is also reducing what an incident can expose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Log security events without logging everything
&lt;/h2&gt;

&lt;p&gt;You need an audit trail for sign-ins, denied authorization checks, tool requests, confirmation decisions, model errors, rate-limit events, and high-impact writes. Each event should include a timestamp, request or job ID, actor ID, workspace ID, action, result, and a safe reason code.&lt;/p&gt;

&lt;p&gt;Do not make the prompt itself the audit trail by default. Store a redacted summary or a content hash unless the raw text is required for a documented support workflow. Make logs append-only for normal application roles, and test that a user cannot read another workspace's events.&lt;/p&gt;

&lt;p&gt;Finally, test the failures you are trying to prevent. Add cases for cross-workspace IDs, expired sessions, oversized inputs, malformed JSON, prompt injection inside retrieved documents, repeated job delivery, unauthorized tool calls, and budget exhaustion. Run them in CI and against a staging project before production access is enabled.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical ship gate
&lt;/h2&gt;

&lt;p&gt;Before calling the feature production-ready, answer yes to these questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is identity derived from a verified server-side session?&lt;/li&gt;
&lt;li&gt;Does every read and write enforce the tenant boundary?&lt;/li&gt;
&lt;li&gt;Are retrieved documents and user messages treated as untrusted data?&lt;/li&gt;
&lt;li&gt;Is every model output parsed and validated for its next use?&lt;/li&gt;
&lt;li&gt;Are tools narrow, permission-checked, rate-limited, and confirmation-gated when needed?&lt;/li&gt;
&lt;li&gt;Are secrets outside source code and client bundles?&lt;/li&gt;
&lt;li&gt;Are spend, input size, time, and step limits enforced?&lt;/li&gt;
&lt;li&gt;Can you investigate a denied action without exposing the customer's prompt?&lt;/li&gt;
&lt;li&gt;Are retries safe for every external side effect?&lt;/li&gt;
&lt;li&gt;Have these cases been tested with hostile input, not only the happy path?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;OTF's &lt;a href="https://otf-kit.dev/templates" rel="noopener noreferrer"&gt;templates page&lt;/a&gt; includes a free AI configs pack for Cursor, Claude, and Lovable. That is a useful starting point for getting an AI coding tool oriented to your project, but the security boundary still belongs in your server code, database policies, provider settings, and tests. Start with the checklist above, then ship one narrow tool at a time.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://genai.owasp.org/resource/owasp-genai-llm-top-10-2026/" rel="noopener noreferrer"&gt;OWASP GenAI LLM Top 10 2026&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/LLM_Prompt_Injection_Prevention_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP LLM Prompt Injection Prevention Cheat Sheet&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>Cursor prompts that keep agent sessions focused from first edit to final check</title>
      <dc:creator>Dave Kurian</dc:creator>
      <pubDate>Sun, 30 Aug 2026 10:35:12 +0000</pubDate>
      <link>https://dev.to/davekurian/cursor-prompts-that-keep-agent-sessions-focused-from-first-edit-to-final-check-520g</link>
      <guid>https://dev.to/davekurian/cursor-prompts-that-keep-agent-sessions-focused-from-first-edit-to-final-check-520g</guid>
      <description>&lt;p&gt;Cursor can make a small feature feel like a conversation: describe the change, watch files move, answer a question, and review the result. That workflow is useful until the task becomes larger than one edit. Then a vague prompt creates a vague session. The agent changes the right screen but misses the empty state, updates a handler without checking authorization, or stops after the code compiles while the feature still cannot be used.&lt;/p&gt;

&lt;p&gt;The fix is not a longer paragraph full of implementation guesses. It is a prompt that gives the agent a bounded job, a definition of done, and a way to show its work.&lt;/p&gt;

&lt;p&gt;Cursor’s CLI changelog makes this more important. Its August 11, 2026 release notes describe delegated subagents, single-turn runs that wait for subagents to finish, queued steering while a turn is active, and durable goals. Those are useful controls for longer sessions, but they do not decide what “finished” means for your product. Your prompt still has to do that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the user-visible result
&lt;/h2&gt;

&lt;p&gt;Open with the result a user should be able to see or complete. Avoid starting with a file name or a list of technologies. The agent needs context, but the acceptance test should come first.&lt;/p&gt;

&lt;p&gt;A weak prompt looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Add an invite flow to the settings page.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It leaves too many decisions open. Who can invite someone? What happens to an existing email? Is an invitation sent immediately? What does the sender see when the request fails?&lt;/p&gt;

&lt;p&gt;A better first pass is specific about the job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Build the workspace invite flow for an owner or admin.

A successful flow lets the user:
- enter one email address
- see whether the invitation was accepted by the server
- understand when that address already belongs to the workspace
- retry after a temporary failure

Do not change the member list layout or billing screens.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives the agent a product boundary before it sees an implementation boundary. It also gives you a useful review question: can a workspace admin complete the invite flow and understand every outcome?&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate facts from decisions
&lt;/h2&gt;

&lt;p&gt;Agent sessions become noisy when the prompt mixes known requirements with guesses. Mark what already exists, then identify decisions the agent should not make without checking.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Known:
- The current settings route already loads the workspace id.
- The server has an authenticated request helper used by the member list.
- The member list displays pending invitations.

Decide by inspecting the repository:
- where the invite request belongs
- which existing validation and error conventions apply
- how tests are organized for authenticated requests

Do not invent a second request helper or duplicate member status logic.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pattern keeps the prompt honest. You are not pretending to know the repository before reading it, and you are not asking the agent to redesign unrelated code. “Inspect first” is often more valuable than prescribing a path that the codebase does not use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give the agent a reconnaissance pass
&lt;/h2&gt;

&lt;p&gt;For a feature that crosses a screen, a server action, and a database, ask for a short inspection before asking for edits. Make the first response a map, not a code dump.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Before changing files, inspect the existing invite, member, and authentication paths.
Return:
1. the files you think will change
2. the data flow from the form to persistence
3. existing authorization checks
4. the relevant test commands
5. any ambiguity that needs a decision

Do not edit yet.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a useful stop point. You can correct a wrong assumption while the cost is still one message. It also creates a record of the agent’s model of the codebase. If the proposed files are surprising, ask why before approving the implementation.&lt;/p&gt;

&lt;p&gt;For a small isolated change, this pass may be unnecessary. For a production feature, it is cheap insurance against a polished edit in the wrong layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define acceptance checks, not just implementation tasks
&lt;/h2&gt;

&lt;p&gt;“Add validation” is an implementation task. “Reject malformed addresses without making a request, and show the server error without clearing the form” is an acceptance check.&lt;/p&gt;

&lt;p&gt;Write checks that a person can verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Acceptance checks:
- An empty email cannot be submitted.
- A malformed email shows an inline message and makes no network request.
- A valid submission disables the submit control while the request is active.
- A successful response adds the pending invite to the visible list.
- An already-used address explains what happened without creating a duplicate.
- A failed response preserves the typed address and gives the user a retry path.
- A non-admin cannot create an invitation, whether the request comes from the screen or a direct call.
- Tests cover the permission check and the duplicate-address case.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice that the list includes more than the happy path. It covers client behavior, server behavior, authorization, and recovery. That is the difference between asking for a feature and asking for a demo-shaped result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tell the agent when to stop
&lt;/h2&gt;

&lt;p&gt;Long sessions need explicit stop conditions. Without them, an agent can keep “improving” nearby code or silently broaden the change.&lt;/p&gt;

&lt;p&gt;Add a scope rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Scope rules:
- Change only files required for this invite flow.
- Do not rename existing public functions.
- Do not migrate unrelated data.
- Do not add a dependency unless the repository has no existing solution; if one is needed, stop and explain why.
- If a test or check fails for an unrelated pre-existing reason, stop and report it instead of rewriting that area.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last line matters. A failing check is information, not an invitation to make the diff larger. The agent should distinguish a regression caused by the change from a failure that was already present.&lt;/p&gt;

&lt;p&gt;For a multi-step session, make the phases visible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Work in these phases:
1. Inspect and report the plan.
2. Wait for confirmation.
3. Implement the smallest complete change.
4. Run the focused checks.
5. Review the diff for scope, authorization, loading, empty, error, and recovery states.
6. Stop and report files changed, checks run, and remaining risks.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you are using an autonomous or headless run, the report in step six becomes part of your review surface. A session that exits after delegated work has completed is only useful if its output tells you what was actually checked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use a compact context block
&lt;/h2&gt;

&lt;p&gt;The best prompts are not necessarily the longest. Put stable repository facts in a compact block so the task itself stays readable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Context:
- This is a multi-tenant app.
- Every workspace-scoped read must be filtered by workspace id.
- Authorization is enforced on the server, not only in the UI.
- Existing tests use the focused test command documented in the repository.
- Preserve the current visual language and interaction patterns.

Task:
Add workspace invitations for owners and admins.

Evidence required before you finish:
- focused tests pass
- the diff shows the server permission check
- the UI covers loading, empty, success, duplicate, and failure states
- the final response lists commands and their results
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A context block gives the agent constraints that are easy to forget while it is editing. It is also reusable: the multi-tenant and server-authorization rules can remain stable while the task changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ask for evidence in the final response
&lt;/h2&gt;

&lt;p&gt;“Done” is not a useful verification record. Ask for facts that let you review the session without replaying every turn.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;In your final response, include:
- a two-sentence summary of the user-visible change
- files changed and why each changed
- commands run and whether each passed
- tests not run and the reason
- any assumptions or follow-up risks
- a short manual test checklist
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This also helps when the agent delegates work. A completed subagent is not the same as a verified feature. The parent session should collect the result, compare it with the acceptance checks, and report gaps plainly.&lt;/p&gt;

&lt;h2&gt;
  
  
  A reusable prompt for production features
&lt;/h2&gt;

&lt;p&gt;Here is the full shape in one place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Goal:
Describe the user-visible result in one paragraph.

Context:
List only verified repository and product facts.

First pass:
*** the relevant paths and report files, data flow, authorization, tests, and ambiguities. Do not edit yet.

Acceptance checks:
List happy path, validation, loading, empty, error, recovery, authorization, and persistence behavior.

Scope rules:
Name areas not to change, rename, migrate, or add to without stopping.

Execution:
Implement the smallest complete change. Preserve existing conventions. Run focused checks after editing.

Evidence:
Report the diff, commands and results, untested areas, assumptions, and manual verification steps.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can shorten this for a one-file change. Keep the same bones whenever the task touches user data, permissions, persistence, or more than one layer.&lt;/p&gt;

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

&lt;p&gt;A good Cursor prompt does three jobs: it gives the agent a reason to change the code, a boundary around the change, and proof requirements for calling it finished. The current tooling makes longer agent sessions easier to steer and inspect, but the product judgment remains yours.&lt;/p&gt;

&lt;p&gt;Start with the outcome. Ask for reconnaissance before edits. Turn edge cases into checks. Set stop conditions. Require evidence instead of accepting a confident summary. That structure keeps an agent session focused without forcing you to micromanage every line.&lt;/p&gt;

&lt;p&gt;For more detail on the CLI behavior behind delegated work, steering, and completion handling, see the &lt;a href="https://cursor.com/docs/cli/changelog" rel="noopener noreferrer"&gt;Cursor CLI changelog&lt;/a&gt;.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Why Cursor's AIUC-1 Certification and Quarterly Re-testing Matters for Your AI Coding Tools</title>
      <dc:creator>Dave Kurian</dc:creator>
      <pubDate>Sat, 29 Aug 2026 09:28:28 +0000</pubDate>
      <link>https://dev.to/davekurian/why-cursors-aiuc-1-certification-and-quarterly-re-testing-matters-for-your-ai-coding-tools-1ila</link>
      <guid>https://dev.to/davekurian/why-cursors-aiuc-1-certification-and-quarterly-re-testing-matters-for-your-ai-coding-tools-1ila</guid>
      <description>&lt;h2&gt;
  
  
  Cursor earns AIUC-1 certification — and the quarterly re-test is the part that matters
&lt;/h2&gt;

&lt;p&gt;Cursor earns AIUC-1 certification, an independent adversarial audit of its coding agent covering prompt injection, secrets exfiltration, MCP misuse, unsafe shell commands, and destructive filesystem actions. That's a real receipt. Most AI coding tools ship a "we take security seriously" paragraph and a SOC 2 badge from 2023. Cursor put itself in front of an external red team and committed to a recurring re-test cadence — not a one-and-done marketing logo.&lt;/p&gt;

&lt;p&gt;This is worth taking seriously. Let me translate the announcement into a checklist production teams can actually use this week, then talk about the part of agent safety that doesn't change when the model does.&lt;/p&gt;

&lt;p&gt;[[COMPARE: one-time security cert vs quarterly adversarial re-test]]&lt;/p&gt;

&lt;h2&gt;
  
  
  What AIUC-1 actually puts under the microscope
&lt;/h2&gt;

&lt;p&gt;The audit spans the surface area where coding agents actually break things in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Secrets protection&lt;/strong&gt; — can the agent be tricked into reading &lt;code&gt;.env&lt;/code&gt;, printing &lt;code&gt;process.env&lt;/code&gt;, or pasting tokens into a commit message, a log line, or a fetched URL?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secure code generation&lt;/strong&gt; — under pressure, does the model produce SQL with string-concatenated user input, &lt;code&gt;eval()&lt;/code&gt; calls, hardcoded API keys, or known-vulnerable dependency patterns?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCP security&lt;/strong&gt; — when an MCP tool is in scope, can a malicious tool description, a poisoned resource, or a confused-deputy call get the agent to exfiltrate or overwrite?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permissions&lt;/strong&gt; — does the agent respect read-only vs write scopes, file allowlists, branch protection, the difference between "edit this PR" and "push to main"?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unsafe commands&lt;/strong&gt; — &lt;code&gt;rm -rf&lt;/code&gt;, &lt;code&gt;git push --force&lt;/code&gt;, &lt;code&gt;curl | sh&lt;/code&gt;, &lt;code&gt;chmod 777&lt;/code&gt;, &lt;code&gt;kubectl delete ns&lt;/code&gt;. The foot-guns that turn an agent mistake into a Friday incident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Destructive actions&lt;/strong&gt; — wiping a database, dropping a bucket, force-pushing, rotating production keys by accident. The audit asks: does the agent ask first?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The point isn't that any one tool "passes." Adversarial testing surfaces tradeoffs, not trophies. The point is that the tradeoffs are now visible, versioned, and re-checked — not buried in a vendor PDF that ages into a logo on the marketing page.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quarterly cadence is the actual headline
&lt;/h2&gt;

&lt;p&gt;Read the announcement again. The receipt is the re-test.&lt;/p&gt;

&lt;p&gt;AI coding agents are not stable software. The model underneath changes every few months. New MCP tools get added. New agent loops get shipped. The "agent" you audited in March is a different system in July. A single certification, even a rigorous one, certifies a snapshot. Quarterly re-testing is what turns that snapshot into a moving baseline — a real signal you can compare release-over-release, vendor-over-vendor.&lt;/p&gt;

&lt;p&gt;For production teams, this is the enable. You can now require:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The vendor has an externally-published adversarial test suite, not a private checklist only they run.&lt;/li&gt;
&lt;li&gt;The vendor commits to a public re-test cadence — quarterly is the new floor; biannual is stale.&lt;/li&gt;
&lt;li&gt;The re-test results are diffable — what regressed, what improved, what was newly added.&lt;/li&gt;
&lt;li&gt;The test surface covers the six categories above: secrets, code-gen, MCP, permissions, unsafe commands, destructive actions.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Diffable is the word that matters most. A pass/fail badge is binary; a diff is a signal you can plot, alert on, and argue about in a postmortem. That checklist didn't exist for AI coding tools in any meaningful form six months ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to actually use this today
&lt;/h2&gt;

&lt;p&gt;If you run a team already shipping with Cursor, Claude Code, or anything similar against real repos, here's what to do this week:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Pin the agent version, not just the CLI&lt;/span&gt;
cursor &lt;span class="nt"&gt;--version&lt;/span&gt;
claude &lt;span class="nt"&gt;--version&lt;/span&gt;

&lt;span class="c"&gt;# 2. Audit the tool surface the agent can actually call&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; ~/.cursor/mcp.json
&lt;span class="nb"&gt;cat&lt;/span&gt; .mcp.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Lock&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;deny-list&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;repo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;layer,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;regardless&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;agent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;behavior&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;.cursor/rules&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;or&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;equivalent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;agent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;config&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"deny"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"Bash(rm -rf:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"Bash(git push --force:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"Bash(curl * | sh)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"Read(.env*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"Read(**/secrets/**)"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"filesystem"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"read:./src"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"write:./src"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- 4. Add an adversarial self-review prompt your agent runs on its own diff --&amp;gt;&lt;/span&gt;
&lt;span class="c"&gt;&amp;lt;!-- ai/prompts/security-review.md --&amp;gt;&lt;/span&gt;
Before any commit, re-read your diff and answer:
&lt;span class="p"&gt;-&lt;/span&gt; Did I touch .env&lt;span class="ge"&gt;*, secrets/, *&lt;/span&gt;.pem, or any credentials?
&lt;span class="p"&gt;-&lt;/span&gt; Did I produce string-concatenated SQL, eval, exec, or pickle?
&lt;span class="p"&gt;-&lt;/span&gt; Did I run rm -rf, --force, or curl|sh?
&lt;span class="p"&gt;-&lt;/span&gt; Did I push, merge, or delete anything outside the current branch?
If yes to any: stop, revert, and ask the user.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 5. Run that prompt in CI on every PR&lt;/span&gt;
bun run ai/prompts/security-review.md &lt;span class="nt"&gt;--on-diff&lt;/span&gt;
&lt;span class="c"&gt;# fail the build if the agent flags any of the four questions&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last step — running the adversarial prompt in CI, on every PR, against the agent's actual diff — is the closest thing you can do today to "AIUC-1 for your own codebase." Most teams that talk about agent safety have never actually done it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two patterns that survive any agent swap
&lt;/h2&gt;

&lt;p&gt;Here's the part that matters once the Cursor news cycle moves on. The agent will change. The model underneath will change. The CLI will rename itself twice in a year. Two layers are durable across every swap:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The configuration layer.&lt;/strong&gt; Deny lists, permission scopes, allowed file globs, MCP allowlists, branch protection rules. These are repo-level artifacts that mean the same thing to every agent. They're also the layer AIUC-1 covers least — it tests the agent's behavior, not your config.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The evaluation layer.&lt;/strong&gt; Adversarial prompts that the agent runs against its own diffs before committing. Run in CI. Versioned with the repo. Diffable across PRs. If a model update silently regresses a safety property — and they do, every model update is effectively a new system — this is what catches it.&lt;/p&gt;

&lt;p&gt;The two layers reinforce each other. A deny-list keeps the agent from running &lt;code&gt;rm -rf&lt;/code&gt;; the eval layer catches the case where it tried. Without the eval, you wouldn't know the deny-list just saved you. Without the config, the eval fires too late.&lt;/p&gt;

&lt;p&gt;[[CONCEPT: guardrails as code — config + eval layers survive any agent swap]]&lt;/p&gt;

&lt;p&gt;If your safety story lives entirely inside the agent's prompt or the vendor's blog post, you don't have a safety story. You have a marketing page.&lt;/p&gt;

&lt;h2&gt;
  
  
  What AIUC-1 doesn't test — and what that means for builders
&lt;/h2&gt;

&lt;p&gt;The audit is scoped to agent-internal behavior: does the agent resist being tricked? It is not scoped to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Whether your repo's secrets were already leaked before the agent touched them.&lt;/li&gt;
&lt;li&gt;Whether your CI runner has the right network egress rules.&lt;/li&gt;
&lt;li&gt;Whether your branch protection actually blocks force-pushes.&lt;/li&gt;
&lt;li&gt;Whether the third-party MCP server you added last Tuesday has a poisoned tool description.&lt;/li&gt;
&lt;li&gt;Whether the agent's "edit this file" actually maps to a scoped, audited write.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In other words: AIUC-1 certifies the agent. It does not certify your system. The durable work — config, evals, CI gates, branch policy, MCP allowlists — is on you. That's the part nobody else can ship for you, and it's the part that has to be re-evaluated every time you change anything in the toolchain. Model swap, MCP upgrade, new agent loop — re-run the evals.&lt;/p&gt;

&lt;h2&gt;
  
  
  The durable layer underneath the tool churn
&lt;/h2&gt;

&lt;p&gt;Use Cursor. Use Claude Code. Use whatever the next agent is, whenever it ships. Treat the AIUC-1 receipt as a baseline you can ask every vendor to match — adversarial testing, public results, quarterly cadence, diffable regressions.&lt;/p&gt;

&lt;p&gt;And then build the part that doesn't change when the model does: repo-level deny lists, permission scopes, adversarial eval prompts in CI, and a checklist the agent has to clear before any commit lands.&lt;/p&gt;

&lt;p&gt;This is exactly the work we treat as a kit feature at OTF, not an afterthought. Every full-stack kit we publish has to clear a 24-item design checklist before it's allowed out the door — a script enforces it, no human override, no "we'll fix it next week." Same pattern applies to agent guardrails: encode the rules as artifacts the agent must read (&lt;code&gt;CLAUDE.md&lt;/code&gt; + &lt;code&gt;.cursorrules&lt;/code&gt;), back them with 20+ tested prompts in &lt;code&gt;ai/prompts/&lt;/code&gt;, run them in CI, diff the results release-over-release. The audit badge is a starting line. The portable guardrail layer — configs, prompts, enforced checklists — is the part that compounds across every model swap, every vendor change, every new MCP server you bolt on.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Open Source Cursor Alternatives: Honest Trade-offs for AI-Powered Editing</title>
      <dc:creator>Dave Kurian</dc:creator>
      <pubDate>Sat, 29 Aug 2026 07:05:40 +0000</pubDate>
      <link>https://dev.to/davekurian/open-source-cursor-alternatives-honest-trade-offs-for-ai-powered-editing-pjm</link>
      <guid>https://dev.to/davekurian/open-source-cursor-alternatives-honest-trade-offs-for-ai-powered-editing-pjm</guid>
      <description>&lt;h2&gt;
  
  
  Six open source Cursor alternatives — and the trade-off each one makes
&lt;/h2&gt;

&lt;p&gt;Open-source Cursor alternatives in 2026 are genuinely exciting. You swap the model in two env vars. You swap the agent in one binary. The agent code is on GitHub, the telemetry is opt-in, and you can point any of these at a local Ollama running on your laptop. Compared to closed editor lock-in, that's a real tailwind for builders who care about auditability and model portability.&lt;/p&gt;

&lt;p&gt;What you give up is polish and pace. Closed editors spent two years fusing chat with the diff view, the file tree, the terminal, and the test runner. Most open alternatives bolt AI onto an editor that wasn't designed around it, or skip the editor entirely and live in the terminal. Knowing which trade-off each tool makes — and which one best reads your repo conventions — is the difference between a productive agent and one that hallucinates your codebase.&lt;/p&gt;

&lt;p&gt;Here's the landscape, with real commands and the honest read on each.&lt;/p&gt;

&lt;h2&gt;
  
  
  The matrix
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Surface&lt;/th&gt;
&lt;th&gt;License&lt;/th&gt;
&lt;th&gt;BYO model&lt;/th&gt;
&lt;th&gt;Editor integration&lt;/th&gt;
&lt;th&gt;Reads repo conventions&lt;/th&gt;
&lt;th&gt;AI-tool configs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Continue&lt;/td&gt;
&lt;td&gt;Editor plugin&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;td&gt;Any OpenAI-compatible&lt;/td&gt;
&lt;td&gt;VS Code, JetBrains&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;~/.continue/config.json&lt;/code&gt; + AGENTS.md&lt;/td&gt;
&lt;td&gt;System prompts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cline&lt;/td&gt;
&lt;td&gt;Editor plugin&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;td&gt;OpenAI / Anthropic-compatible&lt;/td&gt;
&lt;td&gt;VS Code&lt;/td&gt;
&lt;td&gt;CLAUDE.md / rules path&lt;/td&gt;
&lt;td&gt;Per-agent rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Roo Code&lt;/td&gt;
&lt;td&gt;Plugin / fork&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;Any OpenAI-compatible&lt;/td&gt;
&lt;td&gt;VS Code (fork + extension)&lt;/td&gt;
&lt;td&gt;AGENTS.md, custom rules&lt;/td&gt;
&lt;td&gt;Multi-mode rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aider&lt;/td&gt;
&lt;td&gt;CLI&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;td&gt;30+ providers&lt;/td&gt;
&lt;td&gt;External (any editor)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CONVENTIONS.md&lt;/code&gt;, &lt;code&gt;--read&lt;/code&gt; flag&lt;/td&gt;
&lt;td&gt;Per-repo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenHands&lt;/td&gt;
&lt;td&gt;Web / sandbox agent&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;Any OpenAI-compatible&lt;/td&gt;
&lt;td&gt;None — runs in Docker&lt;/td&gt;
&lt;td&gt;Per-session repo upload&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Goose&lt;/td&gt;
&lt;td&gt;CLI / Desktop&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;Anthropic, OpenAI, Ollama, …&lt;/td&gt;
&lt;td&gt;External&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.goose/config.yaml&lt;/code&gt;, extensions&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Pricing is identical across the list: free, you pay the model provider (or run it locally). The differences are workflow, not cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Continue — the plugin you'll actually keep
&lt;/h2&gt;

&lt;p&gt;Continue is the most direct replacement for Cursor's vibe — a sidebar chat, inline edits, autocomplete, an OpenAI-compatible endpoint. It lives in VS Code and JetBrains, the config is plain JSON, and you can run it against a local Ollama or any hosted model.&lt;/p&gt;

&lt;p&gt;The setup is two env vars:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"sk-..."&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OPENAI_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://openrouter.ai/api/v1"&lt;/span&gt;   &lt;span class="c"&gt;# or &lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then in &lt;code&gt;~/.continue/config.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"models"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Claude via OpenRouter"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"openai"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"anthropic/claude-sonnet-4.6"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"apiBase"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"${env:OPENAI_BASE_URL}"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"systemPrompts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Repo conventions"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Read AGENTS.md before any task. Use design tokens from @otfdashkit/tokens, never raw values. Keep prop APIs identical across @otfdashkit/ui and @otfdashkit/ui-native."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Continue reads system prompts on every turn, so a top-level &lt;code&gt;AGENTS.md&lt;/code&gt; plus a &lt;code&gt;systemPrompts&lt;/code&gt; entry pointing at it gives the agent your conventions without restating them every chat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick this if&lt;/strong&gt; you want the inline-edit UX Cursor users love, with the editor and model both swappable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cline — the agent that wants to actually use the shell
&lt;/h2&gt;

&lt;p&gt;Cline is a VS Code extension that treats the model like an agent with tools: read, edit, run, report. Apache-2.0, source fully visible, supports any model with an OpenAI- or Anthropic-compatible API.&lt;/p&gt;

&lt;p&gt;The killer feature is the audit trail — every command the agent wants to run appears in the chat as a permission prompt. You approve, edit, or reject. Most closed-source agents hide that; Cline puts it in the conversation.&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="c1"&gt;// A "rules" file Cline reads on every turn — name and path per your 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;rules&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Read AGENTS.md and ./CONVENTIONS.md before any task.&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;Run `pnpm test` and `pnpm typecheck` before marking a task done.&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;Never edit files under db/migrations/ without a linked ticket.&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Pick this if&lt;/strong&gt; you want an agent that does the whole "read, edit, run, fix" loop in your editor, with every action gated behind your approval.&lt;/p&gt;

&lt;h2&gt;
  
  
  Roo Code — Cline with a different fork
&lt;/h2&gt;

&lt;p&gt;Roo Code started as a fork of Cline and now ships as both a VS Code fork and a standalone extension. License is MIT, the source builds in the open. Same tool-calling architecture as Cline, but it adds multi-agent modes (a planner, a coder, a tester) and a more opinionated UX.&lt;/p&gt;

&lt;p&gt;Drop your conventions into its rules file, point AGENTS.md at the kit's tokens, and any of its modes picks them up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick this if&lt;/strong&gt; you want Cline's tool-calling model but find the planner/coder split useful, and you don't mind a fork.&lt;/p&gt;

&lt;h2&gt;
  
  
  Aider — terminal-native, Git-native
&lt;/h2&gt;

&lt;p&gt;Aider is the one most terminal-native devs reach for first. MIT, Python install, takes a prompt and a file (or a directory) and returns a Git commit. No editor integration — Aider writes diffs, you review them in any tool that reads Git.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;aider-chat

&lt;span class="c"&gt;# point at any model endpoint&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sk-ant-...
aider &lt;span class="nt"&gt;--model&lt;/span&gt; claude-sonnet-4-6 &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="nt"&gt;--map-tokens&lt;/span&gt; 2048 &lt;span class="se"&gt;\&lt;/span&gt;
      src/components/Button.tsx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repo-conventions story is direct — Aider reads &lt;code&gt;CONVENTIONS.md&lt;/code&gt; from the repo root and you can pass additional prompt fragments with &lt;code&gt;--read&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aider &lt;span class="nt"&gt;--read&lt;/span&gt; CONVENTIONS.md &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="nt"&gt;--read&lt;/span&gt; ai/prompts/add-page.md &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="nt"&gt;--message&lt;/span&gt; &lt;span class="s2"&gt;"Add a /docs/components/tabs page for the new Tabs component"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Aider is the one I see in the most CI pipelines — same binary, same flags, deterministic model, a diff at the end.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick this if&lt;/strong&gt; you live in the terminal, want the same agent in CI as in your editor, and review changes through Git rather than a chat panel.&lt;/p&gt;

&lt;h2&gt;
  
  
  OpenHands — the autonomous backend
&lt;/h2&gt;

&lt;p&gt;OpenHands (formerly OpenDevin) is closer to a self-hosted Devin than to an editor plugin. It's a Docker image that spins up a sandboxed environment, clones your repo, and runs an agent loop with full tool access. You send it a task over HTTP, it works in the background, you pull the PR when it's done.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# exact env var names vary by version — check the OpenHands docs for your build&lt;/span&gt;
docker run &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-v&lt;/span&gt; /path/to/repo:/workspace &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;LLM_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OPENROUTER_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;LLM_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://openrouter.ai/api/v1"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;LLM_MODEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"anthropic/claude-sonnet-4.6"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    ghcr.io/all-hands-ai/openhands:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The conventions story is weaker — OpenHands reads files on demand, but it doesn't auto-load an &lt;code&gt;AGENTS.md&lt;/code&gt; the way editor plugins do. You ship conventions to it through the prompt, which is brittle but workable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick this if&lt;/strong&gt; you have long-running, low-supervision work (migrations, ports, batch refactors) you'd rather queue than drive interactively.&lt;/p&gt;

&lt;h2&gt;
  
  
  Goose — Block's open agent
&lt;/h2&gt;

&lt;p&gt;Goose is the open-source agent from Block, MIT licensed. It runs as a CLI and a desktop app, supports a wide model range (Anthropic, OpenAI, Ollama, and more), and has a clean extension system — new tools drop in as Python files.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;goose configure        &lt;span class="c"&gt;# pick provider, paste API key&lt;/span&gt;
goose run &lt;span class="s2"&gt;"refactor the billing service to use the new token names"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Goose reads a per-repo config file, which is the natural slot for an AGENTS.md-style conventions doc.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick this if&lt;/strong&gt; you want a model-agnostic CLI agent with a real extension story and the sanity check of a major vendor using it in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  What every one of them needs from you
&lt;/h2&gt;

&lt;p&gt;Here's the part none of these tools fix. The agent's productivity is bounded by one thing — what it knows about your repo before the first prompt. A frontier model with no context is a smart intern who joined today. A frontier model with documented conventions is the senior engineer who shipped the last three releases.&lt;/p&gt;

&lt;p&gt;Every tool above reads some flavor of repo conventions. The substance is identical regardless of which agent you pick:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- AGENTS.md --&amp;gt;&lt;/span&gt;
&lt;span class="gh"&gt;# Conventions&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Use tokens from @otfdashkit/tokens, never raw color or spacing values
&lt;span class="p"&gt;-&lt;/span&gt; Every component lives in both @otfdashkit/ui (web) and
  @otfdashkit/ui-native — keep the prop API identical
&lt;span class="p"&gt;-&lt;/span&gt; Run &lt;span class="sb"&gt;`pnpm test`&lt;/span&gt; and &lt;span class="sb"&gt;`pnpm typecheck`&lt;/span&gt; before reporting a task done
&lt;span class="p"&gt;-&lt;/span&gt; Don't modify files under db/migrations/ without a linked ticket
&lt;span class="p"&gt;-&lt;/span&gt; New component → add a page under app/(docs)/components/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whether the agent reads it as &lt;code&gt;AGENTS.md&lt;/code&gt;, &lt;code&gt;CONVENTIONS.md&lt;/code&gt;, a rule file in its config, or a system prompt is a config shuffle. A paid kit ($99, Everything Bundle $149) ships the prompt library, &lt;code&gt;CLAUDE.md&lt;/code&gt;, &lt;code&gt;.cursorrules&lt;/code&gt;, and 20+ tested prompts under &lt;code&gt;ai/&lt;/code&gt; — point any of the six agents above at the repo, and the agent has the conventions, the token names, and the file layout it needs on the first turn.&lt;/p&gt;

&lt;p&gt;[[COMPARE: swappable tool layer on the left — agent swapped every six months, model swapped every quarter, editor swapped every couple of years — vs durable context layer on the right — repo conventions, design tokens, prompt library, AGENTS.md — outlives every one of them]]&lt;/p&gt;

&lt;p&gt;The tool is the swappable layer. The context is the durable one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one should you pick?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shipping a Next.js / Node service, you live in VS Code, you want the inline-edit UX&lt;/strong&gt; → Continue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same stack, you want the agent to run shell commands and edit files with you approving each step&lt;/strong&gt; → Cline (or Roo Code if you want the planner/coder split).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You're a terminal-first engineer and you want the same agent in CI as in your editor&lt;/strong&gt; → Aider.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You have a backlog of long-running, low-supervision tasks (migrations, ports, refactors) you'd queue rather than drive interactively&lt;/strong&gt; → OpenHands.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want a model-agnostic CLI agent with a real extension system and a major-vendor sanity check&lt;/strong&gt; → Goose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You're not sure which, and you think the context layer matters more than the agent&lt;/strong&gt; → pick whichever has the smoothest setup, point it at a kit with &lt;code&gt;CLAUDE.md&lt;/code&gt; and &lt;code&gt;ai/prompts/&lt;/code&gt;, and let the conventions outlive the tool.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pick the agent. Keep the conventions. The model changes every quarter, the editor changes every couple of years — your repo's &lt;code&gt;AGENTS.md&lt;/code&gt; lasts as long as the code does.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>enable New Possibilities: Building with the Claude Agent SDK</title>
      <dc:creator>Dave Kurian</dc:creator>
      <pubDate>Fri, 28 Aug 2026 20:09:26 +0000</pubDate>
      <link>https://dev.to/davekurian/enable-new-possibilities-building-with-the-claude-agent-sdk-16fh</link>
      <guid>https://dev.to/davekurian/enable-new-possibilities-building-with-the-claude-agent-sdk-16fh</guid>
      <description>&lt;p&gt;The Claude Agent SDK put something genuinely new on the table for builders: an agent loop that reads a real filesystem, runs real commands, and edits real files — not just a chat box. That's the enable, and it's a real one. This post is what you can actually build with it from a builder's seat, and what the repo the agent lands in has to look like for the loop to be worth running.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the SDK actually puts in your hands
&lt;/h2&gt;

&lt;p&gt;Four commonly-discussed primitives show up in every serious walkthrough, and they're worth naming so the rest of the post makes sense:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A real-filesystem agent loop.&lt;/strong&gt; The agent reads files, edits them, runs shell commands, and iterates on the result. It's not hallucinating an answer; it's acting on disk — with all the screw-ups and recoveries that implies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skills.&lt;/strong&gt; Bundles of instruction plus capability — a folder of files that teach the agent a new trick without retraining the underlying model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hooks.&lt;/strong&gt; Event-driven side effects. A tool call goes out, your code intercepts it, and you can block, log, or augment before it lands.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCP servers.&lt;/strong&gt; Pluggable tool registries. Point the agent at a server that speaks the protocol, and new tools show up — no SDK fork required.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;[[DIAGRAM: a builder feeding an agent loop, with four rails converging on the same workspace — filesystem access, a skills folder, hook handlers, and an MCP server all feeding the same loop]]&lt;/p&gt;

&lt;p&gt;The convergence is the part most demos skip. The agent isn't a chatbot with tools bolted on. It's a loop over a workspace, and everything you wire into that workspace becomes part of what the agent can do. That changes what "using the agent" means day to day — you're not prompting a model, you're configuring a runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  A custom skill, in shape
&lt;/h2&gt;

&lt;p&gt;A skill is roughly a directory of files plus a short manifest describing what it is and when to use it. The agent discovers the folder, reads the manifest, and decides whether the user's intent matches. The match is where the design work happens — a vague manifest fires too often, an over-specific one never fires.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# tree of a real skill (shape — see current docs)&lt;/span&gt;
skills/
  deploy-staging/
    MANIFEST.md        &lt;span class="c"&gt;# what it does, when to fire&lt;/span&gt;
    scripts/
      deploy.sh        &lt;span class="c"&gt;# the actual work&lt;/span&gt;
    examples/
      last-run.log     &lt;span class="c"&gt;# what success looks like&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- skills/deploy-staging/MANIFEST.md --&amp;gt;&lt;/span&gt;
&lt;span class="gh"&gt;# Deploy to staging&lt;/span&gt;

Fire when the user asks to ship, push, or deploy to staging.

&lt;span class="gu"&gt;## Steps&lt;/span&gt;
&lt;span class="p"&gt;1.&lt;/span&gt; Run &lt;span class="sb"&gt;`scripts/deploy.sh`&lt;/span&gt; from the repo root.
&lt;span class="p"&gt;2.&lt;/span&gt; Wait for "build complete" in the output.
&lt;span class="p"&gt;3.&lt;/span&gt; Compare output against &lt;span class="sb"&gt;`examples/last-run.log`&lt;/span&gt; shape; report deltas.
&lt;span class="p"&gt;4.&lt;/span&gt; Never run without &lt;span class="sb"&gt;`DEPLOY_CONFIRM=1`&lt;/span&gt; in env.

&lt;span class="gu"&gt;## When NOT to use this&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Production deploys (different skill).
&lt;span class="p"&gt;-&lt;/span&gt; Local dev server (&lt;span class="sb"&gt;`npm run dev`&lt;/span&gt;).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Manifest filename and exact schema vary by SDK version — treat the above as the shape of the pattern, not the contract.)&lt;/p&gt;

&lt;p&gt;The manifest is the contract with the agent. You didn't teach the model anything — you handed it a folder and told it what's inside. That's a one-shot capability upgrade you can ship today, and the agent's next run picks it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  A hook that catches the bash mistake
&lt;/h2&gt;

&lt;p&gt;Hooks are how you keep an autonomous loop honest. The most useful pattern is a pre-tool-use gate: before the agent runs a shell command, your code gets a veto. This is where you encode the rules a junior engineer would have been told on day one — the agent never was.&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="c1"&gt;// .claude/hooks/block-dangerous-bash.ts&lt;/span&gt;
&lt;span class="c1"&gt;// (Pseudocode — event shape and config keys vary by SDK version;&lt;/span&gt;
&lt;span class="c1"&gt;// see current docs before relying on the exact fields below.)&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;preToolUse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tool&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bash&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;command&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;forbidden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sr"&gt;/rm&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+-rf&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+~&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// home dir wipe&lt;/span&gt;
    &lt;span class="sr"&gt;/git push.*--force/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// force push&lt;/span&gt;
    &lt;span class="sr"&gt;/curl.*&lt;/span&gt;&lt;span class="se"&gt;\|\s&lt;/span&gt;&lt;span class="sr"&gt;*sh/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="c1"&gt;// curl-pipe-shell&lt;/span&gt;
    &lt;span class="sr"&gt;/DROP&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+TABLE/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="c1"&gt;// destructive SQL&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;re&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;forbidden&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;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cmd&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;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;deny&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Blocked by pattern &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;re&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;.claude/settings.json&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(illustrative&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;config&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;see&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;current&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;docs)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PreToolUse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;".claude/hooks/block-dangerous-bash.ts"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent still sees the bash call in its reasoning trace. It just never lands. This is the cheapest way to keep an autonomous loop from doing something irreversible on a Tuesday afternoon, and it composes — you can stack ten hooks, one per concern, and the agent never knows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plug in an MCP server for one tool
&lt;/h2&gt;

&lt;p&gt;MCP is the boring part that's quietly the most capable. You can hand the agent access to a tool it has no native knowledge of — an internal DB, a third-party API client, a search index — by pointing it at a server that speaks the protocol.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;.claude/mcp.json&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(illustrative&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;see&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;current&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;docs)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"&amp;lt;your-integration&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;your-mcp-server-package&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"&amp;lt;YOUR_API_KEY&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"${env:YOUR_API_KEY}"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"&amp;lt;internal-docs&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;".mcp/internal-docs-server.mjs"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent now sees tools prefixed with &lt;code&gt;&amp;lt;your-integration&amp;gt;_*&lt;/code&gt; and &lt;code&gt;&amp;lt;internal-docs&amp;gt;_*&lt;/code&gt; in its available tool list — no SDK code change, no model retraining. New team, new tool, new server, same agent. The protocol is the contract, which is why MCP is a bigger deal than its quiet reputation suggests: it turns "the agent has tools" into "the agent has whatever tools your org decides to ship."&lt;/p&gt;

&lt;h2&gt;
  
  
  The repo is half the agent
&lt;/h2&gt;

&lt;p&gt;The SDK is genuinely good. But — and this is the part that doesn't show up until you run the same agent on two different repos in the same week — the repo it lands in matters as much as the agent itself.&lt;/p&gt;

&lt;p&gt;[[COMPARE: a convention-documented kit repo vs a year-old generated-spaghetti repo, run through the same agent loop]]&lt;/p&gt;

&lt;p&gt;Same agent, same model, same prompt, same task. The delta on first-task success rate is consistent and significant. The model didn't get worse. The substrate did. The agent reads your repo the way a senior engineer reads your repo: if the conventions are clear, it follows them; if they're not, it invents ones that don't exist, and the code drifts a little every iteration.&lt;/p&gt;

&lt;p&gt;Most "the AI slowed down" stories come from this, not from the agent. The agent is doing its job. The job description — the repo — is the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things a substrate needs
&lt;/h2&gt;

&lt;p&gt;None of this is expensive. All of it is mandatory if you want the agent to perform a tier higher than it does in an unstructured repo:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A root-level convention file.&lt;/strong&gt; &lt;code&gt;CLAUDE.md&lt;/code&gt;, &lt;code&gt;.cursorrules&lt;/code&gt;, or &lt;code&gt;AGENTS.md&lt;/code&gt; — whichever your harness reads. It states where components live, how tests run, the canonical way to add a feature, and what NOT to do. One file, top of the tree, written in plain prose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A predictable layout.&lt;/strong&gt; One folder per concern. &lt;code&gt;app/&lt;/code&gt;, &lt;code&gt;components/&lt;/code&gt;, &lt;code&gt;lib/&lt;/code&gt;, &lt;code&gt;db/&lt;/code&gt;, &lt;code&gt;tests/&lt;/code&gt;. The agent doesn't need to be clever — it needs to know where to look. Predictability beats elegance every time for an automated reader.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;prompts/&lt;/code&gt; folder of tested instructions.&lt;/strong&gt; Not vibes — instructions you've actually run, that produced the output you wanted. Twenty verified prompts beats two hundred you wrote once and forgot. Each one is a checked-in contract: "when the user asks for X, do Y."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If your repo has all three, the same agent performs a tier higher than it does in a repo without them. We've measured this across multiple kits. The delta isn't subtle, and it's not a model-side win — it's a substrate-side win.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pre-wired advantage
&lt;/h2&gt;

&lt;p&gt;Use the SDK. And here's the part that doesn't change when the model does.&lt;/p&gt;

&lt;p&gt;Every OTF kit — the SaaS Dashboard, the Fitness app, the Booking one — ships with the three substrate items already in place, so the agent has a substrate worth running on from minute one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;CLAUDE.md&lt;/code&gt; at the root that names where things live and why. Plain prose, no magic.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;.cursorrules&lt;/code&gt; file saying the same thing in the format Cursor wants, so a second agent with a different harness reads the same conventions without you rewriting them.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;prompts/&lt;/code&gt; folder of 20+ tested instructions: "add a new pricing tier", "wire a new Stripe webhook", "add a screen to the mobile app". Each hand-verified to produce working output — not aspirational, machine-checked.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On top of that, a 24-item design checklist is enforced by a script before any kit ships. The conventions aren't aspirational — they're checked at build time, so the substrate the agent lands in is the same substrate the kit shipped. No drift between the doc the agent reads and the code it's editing.&lt;/p&gt;

&lt;p&gt;You drop the agent into the kit, and on day one it knows where the components are, how to add a feature, and what NOT to do. You're not spending week one teaching the agent your conventions. They're already written down.&lt;/p&gt;

&lt;p&gt;[[IMG: clay character confidently running the agent loop inside a structured kit repo, with the chaos of an unstructured legacy repo shrinking in the background]]&lt;/p&gt;

&lt;p&gt;That part doesn't change when Anthropic ships a new SDK version, when a competitor ships a different agent, or when the model under the hood gets swapped. The conventions are durable. The agent is the perishable layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this enables
&lt;/h2&gt;

&lt;p&gt;A second developer — the AI one — that ramps in a day instead of a quarter. Same loop, same hooks, same MCP servers. The only variable is the substrate it's dropped into. Make the substrate legible and the agent pays you back ten times. Leave it as generated spaghetti and the agent becomes the thing you blame for the mess.&lt;/p&gt;

&lt;p&gt;The SDK is the lever. The repo is the lever's length. You decide which one to invest in.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Beyond Pretty Charts: The Essential Features of a Real Financial Dashboard</title>
      <dc:creator>Dave Kurian</dc:creator>
      <pubDate>Fri, 28 Aug 2026 19:05:20 +0000</pubDate>
      <link>https://dev.to/davekurian/beyond-pretty-charts-the-essential-features-of-a-real-financial-dashboard-4of1</link>
      <guid>https://dev.to/davekurian/beyond-pretty-charts-the-essential-features-of-a-real-financial-dashboard-4of1</guid>
      <description>&lt;p&gt;A pricing page screenshot tells you nothing. The difference between a dashboard template and a dashboard you'd actually run your business on lives in the boring parts — the eight edge cases that surface the first time a real user clicks a real button on a real Tuesday.&lt;/p&gt;

&lt;p&gt;Here's the checklist I run every dashboard candidate through, what most templates ship instead, and what the SaaS Dashboard kit at saas.otf-kit.dev already has wired. The point isn't that templates are bad. The point is that pretty and wired are two different products, and the wiring is what compounds.&lt;/p&gt;

&lt;p&gt;[[COMPARE: marketing screenshots of charts vs the eight edge cases the user actually hits]]&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Server pagination on every table
&lt;/h2&gt;

&lt;p&gt;Pretty templates ship a table that fetches everything. That's fine for the demo with 47 rows. It's a wall on day one of production, where transactions are 40M rows and the user clicked "page 2."&lt;/p&gt;

&lt;p&gt;Server pagination is three decisions made correctly:&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="c1"&gt;// page state lives in the URL, not component state&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setPage&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useQueryState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;page&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;parseAsInteger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setPageSize&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useQueryState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;size&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;parseAsInteger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isLoading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useSWR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;transactions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;range&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;([,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&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;fetchTransactions&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;range&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The URL is the source of truth so a refresh doesn't lose the user's place, the browser back button works, and a shared link deep-links to "March, page 4, 25 per page." That last one is the test most templates fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The three states every async surface owes you
&lt;/h2&gt;

&lt;p&gt;Loading, error, empty. One component, three branches. Every screen, every table, every card.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;TransactionsTable&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;range&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;range&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DateRange&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isLoading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;refetch&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useTransactions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;range&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;isLoading&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TableSkeleton&lt;/span&gt; &lt;span class="na"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;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;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ErrorState&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onRetry&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;refetch&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;length&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;EmptyState&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"No transactions in this range"&lt;/span&gt;
      &lt;span class="na"&gt;hint&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Try widening the date filter or check the import job."&lt;/span&gt;
      &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;openImportGuide&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;How to import&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;/&amp;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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DataTable&lt;/span&gt; &lt;span class="na"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The empty state is the one everyone forgets. It's also the one that tells you the template author actually thought about the user. A bare "No data" is a confession. A hint plus a CTA is a product.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Date-range filters that compose
&lt;/h2&gt;

&lt;p&gt;A date picker bolted onto a table is a demo. A date picker that composes with pagination, with the URL, with the export button, and with whatever filter the user adds next is a system.&lt;/p&gt;

&lt;p&gt;The composition rule: the filter is a URL param, the table reads from URL, the export reads from URL, the chart reads from URL. One source of truth, four surfaces.&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="c1"&gt;// filters live in the URL; everything reads them&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;range&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setRange&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useQueryState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;range&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="nx"&gt;parseAsString&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;last_30d&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setAccount&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useQueryState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;account&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;parseAsString&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;all&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useQueryState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="nx"&gt;parseAsString&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;all&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the filter and the table share state via the URL, the export button automatically exports the filtered view. When the agent regenerates the page, the filter survives because it was never in component state to begin with.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Exports that survive a slow database
&lt;/h2&gt;

&lt;p&gt;Exports are where dashboards go to die. The user clicks "Export CSV," the request times out at 30 seconds, the browser shows nothing, the user clicks again, and now the job is queued twice.&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;onExport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;range&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DateRange&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;job&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;startExportJob&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;range&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;       &lt;span class="c1"&gt;// returns a jobId&lt;/span&gt;
  &lt;span class="nx"&gt;toast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;pollExport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Preparing your export…&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Download ready&lt;/span&gt;&lt;span class="dl"&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`Export failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&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;Job-based, polled, with a real toast that survives a page refresh. The CSV isn't generated in the request — it's generated by a worker against the same query the table uses, so pagination and filters apply identically. If your template ships exports as a synchronous &lt;code&gt;Blob&lt;/code&gt; download, it has never met a real database.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Access control at the data layer, not the button
&lt;/h2&gt;

&lt;p&gt;Hiding a button is not access control. A user with &lt;code&gt;reports.read: false&lt;/code&gt; who guesses the URL still sees the report. Access control has to fail closed at the data layer.&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="c1"&gt;// server route guards the query, not the UI&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&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;Request&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;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;requireUser&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reports.read&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;forbidden&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;403&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;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;orgId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;dateFilter&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="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;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The UI hides the link. The server denies the query. The audit log records the attempt either way. This is the boring layer that makes the dashboard safe to hand to a paying customer on day one — and it's the layer most templates leave entirely to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Walking the live SaaS Dashboard kit
&lt;/h2&gt;

&lt;p&gt;saas.otf-kit.dev is the live demo, not a screenshot of one. Auth and Stripe are wired — not stubbed — so the demo runs real sessions and real webhooks. The kit ships full-stack: auth, billing, DB, and Stripe already connected. You clone it, point your keys, and the dashboard is live.&lt;/p&gt;

&lt;p&gt;What's wired out of the box:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Transactions table&lt;/strong&gt; with server pagination, URL-backed filters, and the three states from section 2 — the empty state isn't "No data," it's the actual hint-with-CTA.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MRR / churn charts&lt;/strong&gt; reading from the same query layer as the tables, so the chart and the table never disagree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Customer list&lt;/strong&gt; with role-based columns hidden at the server, not the client. A user without &lt;code&gt;customers.export&lt;/code&gt; cannot export customers, full stop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Export job&lt;/strong&gt; with a polled download URL and a toast that survives the navigation that triggers the download.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Settings → Billing&lt;/strong&gt; running the actual Stripe customer portal, not a mock.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The 24-item design checklist runs as a script before the kit ships, so spacing, focus rings, and dark mode aren't aspirational — they're enforced. The same component primitive renders in dark mode without a second stylesheet because the design tokens flip one theme across the whole surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Adding a new metric screen via the kit's prompts
&lt;/h2&gt;

&lt;p&gt;The kit ships a tested prompt library — &lt;code&gt;CLAUDE.md&lt;/code&gt; plus &lt;code&gt;.cursorrules&lt;/code&gt; plus a folder of prompts the team has run end-to-end against this codebase. The point isn't to write a prompt. The point is that the prompt has already been run against this kit, on this stack, and the result is in the repo.&lt;/p&gt;

&lt;p&gt;A new "Refunds by reason" metric screen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# ai/prompts/06-new-metric-screen.md&lt;/span&gt;

Add a new metric screen to the SaaS Dashboard.

Inputs:
&lt;span class="p"&gt;-&lt;/span&gt; New metric: refunds grouped by reason
&lt;span class="p"&gt;-&lt;/span&gt; Where: under &lt;span class="sb"&gt;`/dashboard/reports`&lt;/span&gt;, visible to users with &lt;span class="sb"&gt;`reports.read`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Visualisation: bar chart + a top-10 table
&lt;span class="p"&gt;-&lt;/span&gt; Date range filter must compose with the existing URL state

Steps:
&lt;span class="p"&gt;1.&lt;/span&gt; Add the query in &lt;span class="sb"&gt;`server/reports/refunds.ts`&lt;/span&gt; using the existing
   &lt;span class="sb"&gt;`dateFilter`&lt;/span&gt; helper and &lt;span class="sb"&gt;`requirePermission('reports.read')`&lt;/span&gt;.
&lt;span class="p"&gt;2.&lt;/span&gt; Add the route in &lt;span class="sb"&gt;`app/dashboard/reports/refunds/page.tsx`&lt;/span&gt;.
&lt;span class="p"&gt;3.&lt;/span&gt; Use &lt;span class="sb"&gt;`DataTable`&lt;/span&gt; for the table, &lt;span class="sb"&gt;`&amp;lt;Card&amp;gt;`&lt;/span&gt; for the chart, and the
   existing &lt;span class="sb"&gt;`DateRangePicker`&lt;/span&gt; — do not introduce new components.
&lt;span class="p"&gt;4.&lt;/span&gt; Wire the export button to &lt;span class="sb"&gt;`startExportJob`&lt;/span&gt;.
&lt;span class="p"&gt;5.&lt;/span&gt; Run &lt;span class="sb"&gt;`pnpm kit:check`&lt;/span&gt; (the 24-item script). All checks must pass.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point Claude Code or Cursor at the prompt. The agent extends the kit instead of regenerating it, because &lt;code&gt;CLAUDE.md&lt;/code&gt; already tells it the conventions — what the existing helpers are, where queries live, what the design tokens are. A prompt without &lt;code&gt;CLAUDE.md&lt;/code&gt; is a coin flip; a prompt with it is a refactor.&lt;/p&gt;

&lt;p&gt;[[CONCEPT: the agent extends the kit instead of regenerating it — CLAUDE.md is the contract]]&lt;/p&gt;

&lt;p&gt;The new screen inherits pagination, filter composition, access control, and the three states for free, because those live in the kit, not in the screen. That is the entire bet: a wired layer underneath the screens, so every new screen ships the boring parts on day one.&lt;/p&gt;

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

&lt;p&gt;A new metric in fifteen minutes, not a new metric after a sprint of "we should also add empty states." The eight-item checklist stops being a backlog and starts being the floor — every screen inherits it because the kit enforces it. That's the difference between a dashboard you demo and a dashboard you run a business on.&lt;/p&gt;

&lt;p&gt;A template can win on screenshots for one launch cycle. It loses the moment the user paginates, filters, exports, or hits the empty state. Build for the second click, not the first. The kit is at saas.otf-kit.dev — clone it, point your keys, and the boring parts are already done.&lt;/p&gt;

&lt;p&gt;[[IMG: the clay character at a laptop, screens behind it showing a new metric live in the dashboard — a small check badge floating over the export button, the empty-state card replaced by real rows, the agent prompt pinned to the side as the receipt of how it shipped]]&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>saas</category>
      <category>software</category>
    </item>
    <item>
      <title>Master Cursor: From Install to Shipping Your First Feature</title>
      <dc:creator>Dave Kurian</dc:creator>
      <pubDate>Fri, 28 Aug 2026 17:07:35 +0000</pubDate>
      <link>https://dev.to/davekurian/master-cursor-from-install-to-shipping-your-first-feature-eee</link>
      <guid>https://dev.to/davekurian/master-cursor-from-install-to-shipping-your-first-feature-eee</guid>
      <description>&lt;p&gt;Cursor lives in the editor, not next to it. That is the entire pitch and it's a real one. Most "AI coding" tools bolt a chat panel to the side and call it integrated; Cursor is the editor with the model wired into the buffer, the selection, and the file tree. You select code, you type a sentence, the code changes. You don't copy a prompt into a website, you don't paste output back. For a working developer that single difference is worth more than every benchmark ever published — it's the difference between using a tool and demoing one.&lt;/p&gt;

&lt;p&gt;This post is the one I wish I had on day one: install Cursor, open a real codebase (not an empty folder), write your conventions &lt;em&gt;before&lt;/em&gt; the first prompt, learn the three core interactions, and ship one feature end to end. The whole thing takes an afternoon. The single non-obvious move — giving the agent a written rulebook — is what separates "it generated plausible code" from "it generated code I can merge."&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Install and open a real repo
&lt;/h2&gt;

&lt;p&gt;Download Cursor from the official site and sign in. The Pro tier is what you want for serious use; the free tier will cap you mid-session in a way that makes the tool feel worse than it is. Don't judge the product on the free tier.&lt;/p&gt;

&lt;p&gt;Skip the "new folder" temptation. Open a real project — a Next.js app, an Express server, anything with at least 20 files and a real dependency graph. The model is dramatically more useful when it can read the surrounding code and copy the conventions it finds. An empty folder is a stress test, not a workflow.&lt;/p&gt;

&lt;p&gt;Let the indexer finish. Cursor builds a vector index of your repo so that chat and agent mode can pull in the right files. It runs in the background; the spinner in the bottom-right tells you when it's done. The first thing most beginners do is start prompting before the index is ready and conclude the tool is bad. The tool is fine. Wait for the index.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The one thing beginners skip: write &lt;code&gt;.cursorrules&lt;/code&gt; before your first prompt
&lt;/h2&gt;

&lt;p&gt;Here's the lesson that costs the most when you skip it. The model's default behaviour is to &lt;em&gt;invent conventions&lt;/em&gt;. It will pick a state management library, a folder layout, a test framework, an error-handling style, and a name for every function — and it will pick differently every prompt. You end up with a codebase that has six ways to do everything, none of which match the rest of the project.&lt;/p&gt;

&lt;p&gt;The fix is a file at the repo root named &lt;code&gt;.cursorrules&lt;/code&gt;. Cursor injects its contents into every prompt, automatically, in the background. You don't @-mention it, you don't paste it in. The model just reads it. This is the single highest-use thing you can do in the first five minutes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Project: SaaS dashboard&lt;/span&gt;
&lt;span class="gh"&gt;# Stack outcome, not stack names: shared components across web and mobile&lt;/span&gt;
&lt;span class="gh"&gt;# Owner: shipped product, not a demo&lt;/span&gt;

&lt;span class="gu"&gt;## Conventions&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Components live in &lt;span class="sb"&gt;`src/components/`&lt;/span&gt;. One file per component. Co-locate the test in &lt;span class="sb"&gt;`__tests__/`&lt;/span&gt;.
&lt;span class="p"&gt;-&lt;/span&gt; Server actions return &lt;span class="sb"&gt;`{ data, error }`&lt;/span&gt;. Never throw across an action boundary.
&lt;span class="p"&gt;-&lt;/span&gt; Money is integer cents. Always. The number of bugs this prevents is not zero.
&lt;span class="p"&gt;-&lt;/span&gt; Use the design tokens from &lt;span class="sb"&gt;`@otfdashkit/tokens`&lt;/span&gt; for color, spacing, type. Hard-coded values are a code-review fail.
&lt;span class="p"&gt;-&lt;/span&gt; Database access goes through the typed query layer in &lt;span class="sb"&gt;`src/db/queries/`&lt;/span&gt;. No raw SQL in route handlers.

&lt;span class="gu"&gt;## What to refuse&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Do not introduce a new dependency without flagging it. Prefer existing libs.
&lt;span class="p"&gt;-&lt;/span&gt; Do not "fix" a file outside the task. Stay scoped.
&lt;span class="p"&gt;-&lt;/span&gt; Do not add a comment that restates the code.

&lt;span class="gu"&gt;## Voice&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Senior engineer. Terse. Code over prose. No "let me explain", no "happy to help".
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things to notice. First, this is &lt;em&gt;outcomes&lt;/em&gt; not libraries — "shared components across web and mobile" rather than naming every framework. The model can adapt as the stack evolves. Second, there is a "refuse" section. Without it, the model will "improve" code you didn't ask it to touch and the diff becomes unreviewable. Third, it has a voice line. The model matches the tone of the prompt; tell it the tone you want.&lt;/p&gt;

&lt;p&gt;Write this file, commit it, then open Cursor for the first time. Reverse this order and you will spend an hour deleting invented conventions.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The three interactions you actually need
&lt;/h2&gt;

&lt;p&gt;Cursor has a lot of surface area. Ignore most of it. Three interactions cover 90% of real work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inline edit (&lt;code&gt;Cmd+K&lt;/code&gt;).&lt;/strong&gt; Select code, type what you want changed, hit enter. This is for surgical, local edits — rename this, add the type, swap the loop for a map, fix the off-by-one. It edits the buffer, you review the diff, you keep moving. Use it for anything you could have done yourself in under two minutes but don't want to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chat (&lt;code&gt;Cmd+L&lt;/code&gt;).&lt;/strong&gt; A side panel. Use it for questions, explanations, and "what does this file do." It does not edit files by default — it's a thinking partner, not a doer. The right time for chat is when you are stuck and want a second opinion faster than reading docs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agent mode (&lt;code&gt;Cmd+I&lt;/code&gt;).&lt;/strong&gt; This is the one people underestimate. You give it a multi-step task — "add a password reset flow that emails a token, expires after 1 hour, and updates the audit log" — and it edits multiple files, runs commands, reads errors, fixes them, and reports back. The quality is a function of three things: the &lt;code&gt;.cursorrules&lt;/code&gt; file, how clearly you describe the goal, and how small the scope. Big vague prompts in agent mode produce big vague diffs. Small specific prompts in agent mode produce small specific diffs that you can actually review.&lt;/p&gt;

&lt;p&gt;The mental model: &lt;code&gt;Cmd+K&lt;/code&gt; is your hands, chat is your head, agent mode is your junior engineer. Use each for what it's good at.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. One end-to-end feature, start to finish
&lt;/h2&gt;

&lt;p&gt;Here is the workflow for shipping a real thing. The example is a "duplicate project" button on a SaaS dashboard. Real code, real steps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1 — scope it in chat.&lt;/strong&gt; Open the project page file in the editor. Hit &lt;code&gt;Cmd+L&lt;/code&gt;, type:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Read &lt;code&gt;src/app/projects/[id]/page.tsx&lt;/code&gt; and &lt;code&gt;src/db/queries/projects.ts&lt;/code&gt;. I want to add a "Duplicate" button to the project header that creates a copy of the project, its tasks, and its members under a new name. Do not write code yet. Tell me what you'd touch, what edge cases you see, and what questions you have.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is the move beginners skip. You are &lt;em&gt;using the model to think&lt;/em&gt;, not to type. Read its answer. Push back on assumptions. "What about the billing relationship?" "What about archived projects?" You are the engineer; the model is the rubber duck on steroids.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2 — agent mode for the implementation.&lt;/strong&gt; Once the scope is sane, hit &lt;code&gt;Cmd+I&lt;/code&gt; and give it the actual task:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Add a server action &lt;code&gt;duplicateProject(projectId, newName)&lt;/code&gt; in &lt;code&gt;src/app/projects/actions.ts&lt;/code&gt;. It should: 1) load the project by id, 2) deep-copy it plus its tasks and members under a new id, 3) set the new name, 4) return the new project id. Use the existing &lt;code&gt;db.transaction&lt;/code&gt; helper. Do not modify the project header component yet — that is the next step.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Notice: scoped, named function, single file, no scope creep. Agent mode will read the existing helpers and copy their style because your &lt;code&gt;.cursorrules&lt;/code&gt; told it to. The diff is reviewable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3 — inline edit for the UI.&lt;/strong&gt; Select the project header JSX, hit &lt;code&gt;Cmd+K&lt;/code&gt;, type:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Add a "Duplicate" button next to the existing "Edit" button. On click, prompt for a name, call the action, then router.refresh().&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's it. Three interactions, three files touched, one feature shipped. Total time: under fifteen minutes for a working dev. The output is in your style because the &lt;code&gt;.cursorrules&lt;/code&gt; told the model what your style &lt;em&gt;is&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. What changes when the conventions are pre-written
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;.cursorrules&lt;/code&gt; file is one file, but it changes the economics of the whole tool. Without it, every prompt reinvents the project. With it, the first prompt and the fiftieth prompt look the same to the model — same conventions, same refusals, same voice. The output stops drifting. Your code reviews stop being a hunt for inconsistencies and start being a hunt for bugs, which is the job.&lt;/p&gt;

&lt;p&gt;This is also why the &lt;em&gt;content&lt;/em&gt; of the file matters. "Use TypeScript" is noise. "Money is integer cents. Always." is the kind of rule that prevents a four-hour debugging session six months from now. The best rules are the ones written from a real scar.&lt;/p&gt;

&lt;p&gt;[[COMPARE: first prompt with no rules vs tenth prompt with .cursorrules]]&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The part that doesn't change when the tool does
&lt;/h2&gt;

&lt;p&gt;Cursor is a fantastic tool. So was the last one, and the one before that, and the one before that. The thing that survives every tool churn is the convention layer — the documented decisions about how &lt;em&gt;your&lt;/em&gt; codebase works, written once, enforced everywhere, legible to humans and machines.&lt;/p&gt;

&lt;p&gt;This is exactly why the kits we ship at OTF include &lt;code&gt;CLAUDE.md&lt;/code&gt;, &lt;code&gt;.cursorrules&lt;/code&gt;, and 20+ tested prompts at install time. Not because we think Cursor is risky — use Cursor, use Claude Code, use whatever ships next week — but because the conventions are the part that outlives the tool. The component is the same &lt;code&gt;&amp;lt;Button&amp;gt;&lt;/code&gt; on web, iOS, and Android, themed by a single token file, audited by a 24-item design checklist before it ships. The AI config is the same kind of layer for the model. Pre-written, pre-tested, version-controlled with the code. The model comes and goes; the rules stay.&lt;/p&gt;

&lt;p&gt;If you want to skip the part where you discover every footgun by hand, the SaaS Dashboard, Fitness, and Booking kits at &lt;code&gt;otf-kit.dev&lt;/code&gt; ship with this whole setup — components, tokens, AI config, deployment script — for $99 each, or the everything bundle for $149. Free MIT components on npm if you just want the building blocks.&lt;/p&gt;

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

&lt;p&gt;By the end of one afternoon you have: a real editor, a real repo, a real convention file, three interactions you understand, and one shipped feature. The next feature is faster because the model already knows the rules. The one after that is faster still. That compounding — not the model, not the editor, the &lt;em&gt;documented conventions&lt;/em&gt; — is the actual product.&lt;/p&gt;

&lt;p&gt;Start with &lt;code&gt;.cursorrules&lt;/code&gt;. Everything else follows.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Why Builders Are Leaving MUI and the Honest Alternatives</title>
      <dc:creator>Dave Kurian</dc:creator>
      <pubDate>Fri, 28 Aug 2026 16:05:16 +0000</pubDate>
      <link>https://dev.to/davekurian/why-builders-are-leaving-mui-and-the-honest-alternatives-2m95</link>
      <guid>https://dev.to/davekurian/why-builders-are-leaving-mui-and-the-honest-alternatives-2m95</guid>
      <description>&lt;p&gt;MUI is one of the most productive React component libraries in the world, and for many teams it's the right call. It's accessible, comprehensive, and ships with enough components that you can build a real product without bolting on third-party pieces. The question isn't whether MUI is good — it is. The question is whether MUI's opinions match your product's opinions, and what to switch to when they don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  MUI is the right answer for some teams
&lt;/h2&gt;

&lt;p&gt;Stay with MUI if you're shipping a B2B SaaS where the Material look reads as "professional," you want a comprehensive single-vendor suite and don't mind the bundle, your designers and PMs like Material's vocabulary, or your mobile story is "responsive web," not native apps. For a CRUD-heavy internal tool, a Material-adjacent aesthetic is a feature, not a bug, and MUI's accessibility story is genuinely strong. The pain isn't MUI. The pain is MUI being the default when something else actually fits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other themed libraries: same shape, different opinions
&lt;/h2&gt;

&lt;p&gt;If the complaint is "Material's specific look" but you still want a comprehensive suite, you can swap MUI for another library of the same shape and pick a different design vocabulary.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Library&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;th&gt;Tradeoff vs MUI&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Chakra UI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Lighter bundles, simpler theming, accessibility by default&lt;/td&gt;
&lt;td&gt;Smaller ecosystem — advanced components like DataGrid and complex date pickers need third-party add-ons&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Mantine&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Dense component set with bundled hooks (forms, notifications, modals manager)&lt;/td&gt;
&lt;td&gt;More npm dependency weight than MUI; CSS-modules-style API is a learning curve if you're coming from emotion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ant Design&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Enterprise density, complex tables, multi-locale admin tools&lt;/td&gt;
&lt;td&gt;Heaviest bundle of the four; Chinese design vocabulary shows through theming work&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Pick this if&lt;/strong&gt; you want a comprehensive single-vendor suite but Material's elevation, ripples, and typography don't match your brand. &lt;strong&gt;Skip it if&lt;/strong&gt; the underlying complaint is "I want my own design system" — these are still vendor opinions, just different ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three things that drive builders away
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Bundle weight.&lt;/strong&gt; The full MUI suite ships with a meaningful per-import footprint. Tree-shaking helps, but the emotion-based runtime is always present, and the moment you pull in an icons package you'll notice. Mobile-first sites feel this first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Material look bleeding through.&lt;/strong&gt; Even after theming, the vocabulary keeps showing. The elevation system, the ripple on every Button, the FAB, the dense Tabs, the Typography scale — Material's design language is what you bought, and "make this look less Google" is harder than it should be.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Theme fights.&lt;/strong&gt; Overriding MUI feels like a constant negotiation. Want a flat button with no ripple? You fight the default props. Want a Paper without the elevation overlay? You fight the theme. The library was built to express Material's design system, and any other design system is a translation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;theme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createTheme&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;components&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;MuiButton&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;styleOverrides&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;root&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;borderRadius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;textTransform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;boxShadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;MuiPaper&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;styleOverrides&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;root&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;backgroundImage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="c1"&gt;// ... and this list never ends&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each override is small. The accumulated cost is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Headless primitives: you bring the styles
&lt;/h2&gt;

&lt;p&gt;The most aesthetic-opinion-free escape from MUI: a library that gives you behavior, accessibility, and keyboard handling, but no CSS. You bring a stylesheet or a utility-class system. Total control, small bundles, and every pixel is yours to write.&lt;/p&gt;

&lt;p&gt;The two exemplars searchers compare are &lt;strong&gt;Radix Primitives&lt;/strong&gt; (unstyled, composable, the de-facto choice for utility-class-first projects) and &lt;strong&gt;Headless UI&lt;/strong&gt; (smaller surface, tightly scoped to what utility classes usually need).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Dialog&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;your-headless-lib&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Root&lt;/span&gt; &lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;onOpenChange&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;setOpen&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Trigger&lt;/span&gt; &lt;span class="na"&gt;asChild&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"rounded-md bg-slate-900 px-3 py-2 text-white"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      Open
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Trigger&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Portal&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Overlay&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"fixed inset-0 bg-black/40"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Content&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-white p-6 rounded-lg shadow-xl"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      ...
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Portal&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="na"&gt;Dialog&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Root&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Pick this if&lt;/strong&gt; you have a strong design system already, you want pixel-perfect control, and you don't mind writing CSS. &lt;strong&gt;Skip it if&lt;/strong&gt; you want to ship a screen tonight and your design system is "whatever looks reasonable."&lt;/p&gt;

&lt;h2&gt;
  
  
  Utility-class-first sets: the current default
&lt;/h2&gt;

&lt;p&gt;The CLI-copied-into-your-repo pattern. You install a tool, run a command, and a &lt;code&gt;Button.tsx&lt;/code&gt; lands in your repo. From then on the component is yours — edit it freely, no upstream fight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;shadcn/ui&lt;/strong&gt; is the exemplar searchers compare against: it composes headless primitives with utility classes, owns nothing, and ships CLI commands that drop components into your repo. Other implementations in this bucket follow the same pattern.&lt;/p&gt;

&lt;p&gt;The upside is speed-to-pixel and ownership. The downside is exactly ownership — when the upstream ships a bugfix or a new component, you don't get it automatically; you diff and merge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick this if&lt;/strong&gt; you want speed, you want the components in your repo so you can edit them freely, and you accept that "upgrades" become a chore. &lt;strong&gt;Skip it if&lt;/strong&gt; you want a maintained vendor you can outsource component bugs to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cross-platform SDKs: when mobile is on the roadmap
&lt;/h2&gt;

&lt;p&gt;Different problem entirely. You also ship iOS and Android, ideally from one codebase, and "responsive web" stopped being the answer last year. Web-only sets don't help here — the native side is a separate decision.&lt;/p&gt;

&lt;p&gt;A cross-platform SDK gives you a single component API that compiles to a web bundle, a native iOS bundle, and a native Android bundle from the same source. The renderers differ per platform (DOM, UIKit, Android views), but the API doesn't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@otfdashkit/ui&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Button&lt;/span&gt; &lt;span class="nx"&gt;intent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;primary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="nx"&gt;onPress&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nx"&gt;Save&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/Button&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same JSX, same prop names, same intent values. Design tokens flip one theme across all three targets so a Card looks identical in Safari, in a native iOS app, and in a native Android app.&lt;/p&gt;

&lt;p&gt;[[DIAGRAM: one component file renders to a web bundle, a native iOS bundle, and a native Android bundle from the same component API and shared design tokens]]&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick this if&lt;/strong&gt; iOS and Android are on the roadmap and "responsive web" isn't good enough. &lt;strong&gt;Skip it if&lt;/strong&gt; mobile is "maybe someday" — the cross-platform overhead earns itself when you actually ship both targets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking by constraint
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Constraint&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Material look is the look&lt;/td&gt;
&lt;td&gt;Stay with MUI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Comprehensive suite, but not Material&lt;/td&gt;
&lt;td&gt;Chakra, Mantine, or Ant Design&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pixel-perfect, design system already exists&lt;/td&gt;
&lt;td&gt;Headless primitives (Radix, Headless UI)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speed-to-pixel, own the code&lt;/td&gt;
&lt;td&gt;Utility-class-first (shadcn/ui)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web + iOS + Android from one codebase&lt;/td&gt;
&lt;td&gt;Cross-platform SDK&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The mistake is picking by trend. The right choice follows from your hardest constraint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where OTF fits
&lt;/h2&gt;

&lt;p&gt;OTF is the cross-platform option. The free SDK ships roughly 200 components on npm under MIT, with the same component name and props on web, iOS, and Android. Design tokens flip a theme across all three targets so a single source of truth covers everything.&lt;/p&gt;

&lt;p&gt;What makes the SDK different from a normal cross-platform component library is what ships with it for AI coding agents. Every kit includes &lt;code&gt;CLAUDE.md&lt;/code&gt; and &lt;code&gt;.cursorrules&lt;/code&gt; configs plus 20+ tested prompts in &lt;code&gt;ai/prompts/&lt;/code&gt; — so when Cursor or Claude Code opens the repo, the agent extends the kit instead of regenerating it. You ask for a &lt;code&gt;DataTable&lt;/code&gt; in chat and you get the OTF &lt;code&gt;DataTable&lt;/code&gt;, not a utility-class hallucination.&lt;/p&gt;

&lt;p&gt;For shipping, one script wires a custom domain, DNS, TLS, and a mobile build in a single command. Quality is enforced by a 24-item design checklist that runs before any kit ships — no hand-waving about "looks right on my machine."&lt;/p&gt;

&lt;p&gt;For the "just ship it" version of the same problem, OTF also publishes paid full-stack kits ($99 each, Everything Bundle $149) — SaaS Dashboard, Fitness, Booking — with auth, billing, DB, and Stripe wired, plus 15 landing templates at $9. The free SDK is enough to evaluate; the kits are what you reach for when the deadline is real. Live demos at saas.otf-kit.dev and fitness-preview.otf-kit.dev.&lt;/p&gt;

&lt;p&gt;OTF doesn't compete with MUI, Chakra, Mantine, Ant Design, Radix, Headless UI, or shadcn/ui on the web-only question. Those are great answers when the deliverable is a web app. When iOS and Android are also on the deliverable, and you want one component source of truth instead of three, that's where a cross-platform SDK earns its place.&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
