<?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: Lovanaut </title>
    <description>The latest articles on DEV Community by Lovanaut  (@lovanaut55).</description>
    <link>https://dev.to/lovanaut55</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3841154%2Fcbe410ba-7007-4a46-9d11-824cfc7dd19a.png</url>
      <title>DEV Community: Lovanaut </title>
      <link>https://dev.to/lovanaut55</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lovanaut55"/>
    <language>en</language>
    <item>
      <title>From Form Response to Figma Wireframe: MCP Orchestration in Practice</title>
      <dc:creator>Lovanaut </dc:creator>
      <pubDate>Tue, 14 Apr 2026 07:35:12 +0000</pubDate>
      <link>https://dev.to/lovanaut55/from-form-response-to-figma-wireframe-mcp-orchestration-in-practice-28id</link>
      <guid>https://dev.to/lovanaut55/from-form-response-to-figma-wireframe-mcp-orchestration-in-practice-28id</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw4i1di3njob2htucwwqk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw4i1di3njob2htucwwqk.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;My &lt;a href="https://dev.to/lovanaut55/formlova-mcp-cross-service-orchestration"&gt;previous Dev.to post&lt;/a&gt; described MCP cross-service orchestration in general terms -- how form responses become Slack messages, Linear issues, or GitHub PRs through LLM-mediated chains. This post is a concrete implementation of that pattern: a structured client hearing form whose responses auto-generate a landing page wireframe in Figma.&lt;/p&gt;

&lt;p&gt;The test case is GreenLeaf Analytics, an AI-powered SaaS for e-commerce cart recovery. A 28-question hearing form captures business context, target audience, content, design preferences, and assets. The responses feed into the Figma Plugin API to produce a multi-section wireframe in about 3 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;Two MCP servers, one client, zero integrations.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MCP Client (Claude Desktop / Cursor)
  |
  |-- 1. FORMLOVA MCP: get_responses(form_id)
  |       → Structured JSON: 28 hearing answers
  |
  |-- 2. LLM: Interprets responses, builds Figma API commands
  |
  |-- 3. Figma MCP: create_new_file(name)
  |       → Empty Figma file
  |
  |-- 4. Figma MCP: use_figma(commands)
          → Wireframe sections built via Plugin API
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;FORMLOVA does not know about Figma. Figma does not know about FORMLOVA. The LLM reads the output of step 1 and constructs the input for steps 3-4. This is the same pattern from the cross-service orchestration post, but applied to a specific, testable workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Hearing Form: 5 Steps, 28 Questions
&lt;/h2&gt;

&lt;p&gt;The hearing sheet is a multi-page form designed from the experience of &lt;a href="https://crowdworks.jp/public/employees/2305166" rel="noopener noreferrer"&gt;over 100 website projects&lt;/a&gt;. The step order mirrors how a designer processes information.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Content&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Business context&lt;/td&gt;
&lt;td&gt;Company name, industry, competitive advantage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Target audience and goals&lt;/td&gt;
&lt;td&gt;Who visits, what they should do&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Content to include&lt;/td&gt;
&lt;td&gt;Headline, CTA copy, metrics, section selection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Design direction&lt;/td&gt;
&lt;td&gt;Mood, colors, fonts, first-view layout, things to avoid&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Assets and requests&lt;/td&gt;
&lt;td&gt;Logo, photos, references, additional requirements&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three questions have the highest impact on wireframe accuracy:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Design elements to avoid" (Step 4).&lt;/strong&gt; Asking what clients like produces vague answers. Asking what they dislike produces constraints. "No stock illustrations." "Nothing cluttered." One negative constraint narrows the design space more than three positive preferences.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"First-view layout" (Step 4).&lt;/strong&gt; Five options: full-width photo overlay, split layout, illustration, text-centered, video background. Without options, clients say "something nice." With options, they make a concrete choice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Sections to include" (Step 3).&lt;/strong&gt; A multi-select with 13 options -- hero, problem, solution, features, numbers, pricing, testimonials, flow, FAQ, team, blog, final CTA, contact. Only selected sections are generated in the wireframe.&lt;/p&gt;

&lt;p&gt;The English hearing form is live: &lt;a href="https://formlova.com/WBtSAMhw9G" rel="noopener noreferrer"&gt;formlova.com/WBtSAMhw9G&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Figma Plugin API: Key Implementation Patterns
&lt;/h2&gt;

&lt;p&gt;The Figma MCP's &lt;code&gt;use_figma&lt;/code&gt; tool executes code in the Figma Plugin API sandbox. There are specific constraints that shape the implementation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sandbox Limitations
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;No &lt;code&gt;fetch()&lt;/code&gt; -- no external network requests&lt;/li&gt;
&lt;li&gt;No external image loading -- all images become gray placeholders&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;require()&lt;/code&gt; or module imports&lt;/li&gt;
&lt;li&gt;Only fonts installed in the Figma environment are available&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is why the output is a wireframe, not a finished design. Images cannot be inserted programmatically, so every image position is a labeled placeholder frame.&lt;/p&gt;

&lt;h3&gt;
  
  
  The appendChild-then-FILL Constraint
&lt;/h3&gt;

&lt;p&gt;This is the most important thing to know about Figma Plugin API Auto Layout. &lt;code&gt;layoutSizingHorizontal: "FILL"&lt;/code&gt; only works after the frame has been added to an Auto Layout parent.&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;// Works&lt;/span&gt;
&lt;span class="nx"&gt;mainFrame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;section&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;section&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;layoutSizingHorizontal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;FILL&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Does not work -- FILL is silently ignored&lt;/span&gt;
&lt;span class="nx"&gt;section&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;layoutSizingHorizontal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;FILL&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;mainFrame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;section&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This constraint applies to every FILL assignment in the entire wireframe -- sections, text nodes, card rows, everything.&lt;/p&gt;

&lt;h3&gt;
  
  
  Font Loading Is Mandatory
&lt;/h3&gt;

&lt;p&gt;Text node manipulation requires pre-loaded fonts. Setting &lt;code&gt;characters&lt;/code&gt; without loading the font throws an error.&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;await&lt;/span&gt; &lt;span class="nx"&gt;figma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loadFontAsync&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Inter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Regular&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;figma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loadFontAsync&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Inter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Bold&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;figma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loadFontAsync&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Inter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Semi Bold&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// Must complete before any text creation&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Main Frame Structure
&lt;/h3&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;mainFrame&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;figma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createFrame&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;mainFrame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1440&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;mainFrame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;layoutMode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;VERTICAL&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;mainFrame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;primaryAxisSizingMode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AUTO&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Height grows with content&lt;/span&gt;
&lt;span class="nx"&gt;mainFrame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;counterAxisSizingMode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;FIXED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Width stays at 1440px&lt;/span&gt;
&lt;span class="nx"&gt;mainFrame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;itemSpacing&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="c1"&gt;// Sections touch edge-to-edge&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each section is a full-width child frame with its own padding, background color, and internal layout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mapping Hearing Responses to Wireframe Elements
&lt;/h2&gt;

&lt;p&gt;The GreenLeaf Analytics test produced these mappings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hearing Item&lt;/th&gt;
&lt;th&gt;Wireframe Element&lt;/th&gt;
&lt;th&gt;Implementation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Headline: "Stop losing sales. Start recovering them."&lt;/td&gt;
&lt;td&gt;Hero heading&lt;/td&gt;
&lt;td&gt;Direct text placement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CTA: "Start free trial"&lt;/td&gt;
&lt;td&gt;Nav + Hero + Final CTA&lt;/td&gt;
&lt;td&gt;Same text in 3 button instances&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Main color: #059669&lt;/td&gt;
&lt;td&gt;CTA buttons, accents&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;hexToRgb()&lt;/code&gt; → &lt;code&gt;fills&lt;/code&gt; property&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First view: "split"&lt;/td&gt;
&lt;td&gt;Hero layout&lt;/td&gt;
&lt;td&gt;&lt;code&gt;layoutMode: "HORIZONTAL"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mood: "Modern &amp;amp; tech-forward"&lt;/td&gt;
&lt;td&gt;Spacing, dark sections&lt;/td&gt;
&lt;td&gt;Config lookup table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 metrics (2,800+, 35%, $48M, etc.)&lt;/td&gt;
&lt;td&gt;Numbers section&lt;/td&gt;
&lt;td&gt;Parsed into large display text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Avoid: "No stock photos"&lt;/td&gt;
&lt;td&gt;Placeholders only&lt;/td&gt;
&lt;td&gt;No illustration elements generated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3-tier pricing (Growth highlighted)&lt;/td&gt;
&lt;td&gt;Pricing cards&lt;/td&gt;
&lt;td&gt;Center card gets dark background + badge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security badges: SOC 2, GDPR&lt;/td&gt;
&lt;td&gt;Final CTA section&lt;/td&gt;
&lt;td&gt;Badge elements in trust row&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Of 28 hearing items, 14 map directly to wireframe elements. The remaining 14 are used indirectly -- business description generates FAQ questions, target audience influences section copy, competitive advantages shape the solution section narrative.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mood-to-Design Parameter Translation
&lt;/h3&gt;

&lt;p&gt;The "mood" dropdown answer translates to concrete design parameters:&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;moodConfigs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;modern_tech&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;sectionPadding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;96&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;cardBorderRadius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;useDarkSections&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;warm_friendly&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;sectionPadding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;72&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;cardBorderRadius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;useDarkSections&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;luxury_refined&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;sectionPadding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;96&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;cardBorderRadius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;useDarkSections&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;minimal_clean&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;sectionPadding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;112&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;cardBorderRadius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;useDarkSections&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This lookup table eliminates LLM interpretation variance. "Modern &amp;amp; tech-forward" always produces 12px border radius and dark sections.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dynamic Section Construction
&lt;/h2&gt;

&lt;p&gt;Only sections selected in the hearing form are generated. The builder pattern:&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;builders&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="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;Response&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;FrameNode&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;hero&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="nx"&gt;buildHero&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;problem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="nx"&gt;buildProblem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;solution&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="nx"&gt;buildSolution&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;features&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="nx"&gt;buildFeatures&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;numbers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="nx"&gt;buildNumbers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;pricing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="nx"&gt;buildPricing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;cases&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="nx"&gt;buildCases&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;faq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="nx"&gt;buildFaq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;cta_bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;buildCtaBottom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// ... all 13 section types&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;key&lt;/span&gt; &lt;span class="k"&gt;of&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;selectedSections&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;builder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;builders&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&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;builder&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;section&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;builder&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;mainFrame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;section&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;section&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;layoutSizingHorizontal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;FILL&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each builder function reads from the hearing response to populate its content. The hero builder switches layout direction based on the first-view selection. The numbers builder parses metric strings into large display text. The pricing builder highlights the recommended tier.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Focu0loj9t0ybyfxp3mdp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Focu0loj9t0ybyfxp3mdp.png" alt=" "&gt;&lt;/a&gt; &lt;/p&gt;

&lt;h2&gt;
  
  
  Test Results
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Test case:&lt;/strong&gt; GreenLeaf Analytics (AI-powered cart recovery SaaS)&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hearing form generation (Recipe 1)&lt;/td&gt;
&lt;td&gt;~2 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test response entry&lt;/td&gt;
&lt;td&gt;~1 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Figma wireframe generation (Recipe 2)&lt;/td&gt;
&lt;td&gt;~3 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~7 min&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Generated Figma file: &lt;a href="https://www.figma.com/design/wVUuV0asZguonuTCoGHTck" rel="noopener noreferrer"&gt;GreenLeaf Analytics - LP Wireframe&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Interactive prototype: &lt;a href="https://www.figma.com/proto/wVUuV0asZguonuTCoGHTck/GreenLeaf-Analytics---LP-Wireframe?node-id=1-2&amp;amp;p=f&amp;amp;scaling=min-zoom&amp;amp;content-scaling=fixed&amp;amp;page-id=0%3A1" rel="noopener noreferrer"&gt;View prototype&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;10 sections + navigation + footer, all with Auto Layout. Colors, spacing, and layout direction derived from hearing responses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest Limitations
&lt;/h2&gt;

&lt;p&gt;The generated wireframe is a starting point, not a deliverable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No images.&lt;/strong&gt; The sandbox constraint means every image position is a gray placeholder. For projects where photography defines the design direction, placeholders alone are insufficient.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Desktop only.&lt;/strong&gt; The output is a 1440px wireframe. Mobile layouts require separate design work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Typography needs refinement.&lt;/strong&gt; Font sizes, line heights, and letter spacing are set to reasonable defaults, but they are not tuned to the brand. A designer still needs to adjust these.&lt;/p&gt;

&lt;p&gt;The value proposition is time compression, not replacement. A wireframe that takes 3-4 hours to build from scratch appears in 3 minutes as a starting point. If the starting point is good enough, refinement takes 1 hour instead of 4.&lt;/p&gt;

&lt;h2&gt;
  
  
  Figma vs Canva
&lt;/h2&gt;

&lt;p&gt;The same hearing data was tested with Canva MCP. Different tools for different stages.&lt;/p&gt;

&lt;p&gt;Figma produces structurally accurate wireframes -- 1440px width, Auto Layout, each section in its own frame. A designer can continue directly from where the automation stopped.&lt;/p&gt;

&lt;p&gt;Canva produces polished-looking slides from templates, but the output is not structured for production handoff. It works for proposals and pitch decks, not for design-to-development pipelines.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Prompts
&lt;/h2&gt;

&lt;p&gt;Both recipe prompts are published on FORMLOVA's &lt;a href="https://formlova.com/en/workflows" rel="noopener noreferrer"&gt;Workflow Place&lt;/a&gt;. Recipe 1 generates the hearing form. Recipe 2 takes the latest response and builds the Figma wireframe. Copy and paste to run.&lt;/p&gt;

&lt;p&gt;The full article with embedded Figma prototypes: &lt;a href="https://formlova.com/en/blog/hearing-sheet-to-figma-wireframe-en" rel="noopener noreferrer"&gt;Turn Site Design Hearings into Forms&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;If you are working with MCP cross-service flows, particularly involving Figma, I would be interested to hear about the patterns you have found useful. The Plugin API sandbox is a meaningful constraint that shapes what is possible.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/lovanaut55/formlova-mcp-cross-service-orchestration"&gt;Your Form Response Just Created a GitHub PR: Cross-Service Orchestration With MCP&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/lovanaut55/118-mcp-tools-4-safety-levels-building-a-server-enforced-form-ops-layer-16j4"&gt;127 MCP Tools, 4 Safety Levels&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://formlova.com/WBtSAMhw9G" rel="noopener noreferrer"&gt;Hearing form (English)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://formlova.com/en/signup" rel="noopener noreferrer"&gt;Get started free&lt;/a&gt; | &lt;a href="https://formlova.com/en/setup" rel="noopener noreferrer"&gt;Setup guide&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>automation</category>
      <category>design</category>
      <category>llm</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Your Form Response Just Created a GitHub PR: Cross-Service Orchestration With MCP</title>
      <dc:creator>Lovanaut </dc:creator>
      <pubDate>Wed, 08 Apr 2026 09:29:04 +0000</pubDate>
      <link>https://dev.to/lovanaut55/your-form-response-just-created-a-github-pr-cross-service-orchestration-with-mcp-57hl</link>
      <guid>https://dev.to/lovanaut55/your-form-response-just-created-a-github-pr-cross-service-orchestration-with-mcp-57hl</guid>
      <description>&lt;p&gt;My previous post covered how &lt;a href="https://dev.to/lovanaut55/118-mcp-tools-4-safety-levels-building-a-server-enforced-form-ops-layer-16j4"&gt;FORMLOVA classifies 127 MCP tools into 4 safety levels&lt;/a&gt;. That was about making a single MCP server safe. This post is about what happens when multiple MCP servers share the same client.&lt;/p&gt;

&lt;p&gt;The premise is simple: if a user has FORMLOVA and Slack and Linear and GitHub all connected to the same MCP client, the LLM can pass data between them. A form response becomes a Slack message becomes a Linear issue becomes a GitHub PR. No webhooks, no Zapier, no integration code.&lt;/p&gt;

&lt;p&gt;This is not theoretical. Every service mentioned here has a production MCP server. I tested the cross-service flows.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture: There Is No Integration
&lt;/h2&gt;

&lt;p&gt;Traditional integrations look 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;Form Service --webhook--&amp;gt; Middleware (Zapier/Make) --API--&amp;gt; Slack
                                                   --API--&amp;gt; HubSpot
                                                   --API--&amp;gt; Linear
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You build connectors. You maintain them. When an API changes, your integration breaks.&lt;/p&gt;

&lt;p&gt;MCP cross-service orchestration looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;User&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Post&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;new&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;bug&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;report&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Slack&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;create&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Linear&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;issue"&lt;/span&gt;

&lt;span class="na"&gt;LLM&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;1. Calls FORMLOVA MCP&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;get_responses(form_id, limit=1)&lt;/span&gt;
  &lt;span class="na"&gt;2. Calls Slack MCP&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;post_message(channel="#bugs", text=formatted_response)&lt;/span&gt;
  &lt;span class="na"&gt;3. Calls Linear MCP&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;create_issue(title=bug_title, description=bug_details)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The LLM is the integration layer. Each MCP call is independent. The services do not know about each other. The LLM reads the output of one call and uses it as input for the next.&lt;/p&gt;

&lt;p&gt;This means: FORMLOVA does not need a Slack integration. Or a Linear integration. Or a HubSpot integration. The user's MCP client handles the orchestration, and each service only needs to expose its own tools well.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Works Today
&lt;/h2&gt;

&lt;p&gt;I tested cross-service flows with the following MCP servers, all of which have official production implementations:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;MCP Server&lt;/th&gt;
&lt;th&gt;Key Capabilities&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Slack&lt;/td&gt;
&lt;td&gt;Official (GA Feb 2026)&lt;/td&gt;
&lt;td&gt;Search, post messages, manage channels&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Notion&lt;/td&gt;
&lt;td&gt;Official (hosted)&lt;/td&gt;
&lt;td&gt;Read/write pages and databases&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Linear&lt;/td&gt;
&lt;td&gt;Official (remote)&lt;/td&gt;
&lt;td&gt;Create/update issues, projects, milestones&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Workspace&lt;/td&gt;
&lt;td&gt;Official&lt;/td&gt;
&lt;td&gt;Calendar, Sheets, Gmail, Drive, Docs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HubSpot&lt;/td&gt;
&lt;td&gt;Official&lt;/td&gt;
&lt;td&gt;Contacts, deals, lists, workflows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Salesforce&lt;/td&gt;
&lt;td&gt;Official&lt;/td&gt;
&lt;td&gt;CRUD on leads, contacts, opportunities&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub&lt;/td&gt;
&lt;td&gt;Official&lt;/td&gt;
&lt;td&gt;Repos, issues, PRs, branches, file ops&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shopify&lt;/td&gt;
&lt;td&gt;Official (default on all stores)&lt;/td&gt;
&lt;td&gt;Products, orders, customers, inventory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stripe&lt;/td&gt;
&lt;td&gt;Official&lt;/td&gt;
&lt;td&gt;Payments, refunds, invoices, subscriptions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Asana&lt;/td&gt;
&lt;td&gt;Official&lt;/td&gt;
&lt;td&gt;Tasks, projects, members&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Atlassian&lt;/td&gt;
&lt;td&gt;Official&lt;/td&gt;
&lt;td&gt;Jira issues, Confluence pages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Twilio&lt;/td&gt;
&lt;td&gt;Official (Alpha)&lt;/td&gt;
&lt;td&gt;SMS, phone calls&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each of these servers exposes tools that an MCP client can call. When multiple servers are active in the same session, the LLM can chain them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five Patterns That Actually Work
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Pattern 1: Bug Report to Code Fix
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;FORMLOVA + GitHub + Linear + Slack&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A user submits a bug report through a form. The response contains: description, reproduction steps, severity, and environment info.&lt;/p&gt;

&lt;p&gt;From a single conversation:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;get_responses&lt;/code&gt; -- pull the latest bug report from FORMLOVA (L0)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;search_code&lt;/code&gt; -- GitHub MCP searches the repo for the relevant code path&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;create_issue&lt;/code&gt; -- Linear MCP creates a prioritized issue with reproduction steps&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;post_message&lt;/code&gt; -- Slack MCP posts to #bugs with the issue link&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;create_branch&lt;/code&gt; -- GitHub MCP creates a fix branch&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;push_files&lt;/code&gt; -- GitHub MCP commits the fix&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;create_pull_request&lt;/code&gt; -- GitHub MCP opens a PR referencing the Linear issue&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 5-7 are the dangerous part. The LLM is writing and committing code based on a form response. For simple bugs -- typos, config errors, obvious logic fixes -- this works remarkably well. For complex bugs, steps 1-4 alone save significant triage time.&lt;/p&gt;

&lt;p&gt;The important nuance: the LLM decides at each step whether to continue. If the code search in step 2 returns ambiguous results, it can stop and ask the user. This is not a rigid automation pipeline. It is a conversational workflow where the human stays in the loop.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 2: Lead Capture to Sales Pipeline
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;FORMLOVA + HubSpot + Slack + Google Calendar&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A prospect fills out a demo request form. The response includes: company name, role, use case, and preferred meeting time.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;get_responses&lt;/code&gt; -- pull the demo request from FORMLOVA&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;create_contact&lt;/code&gt; -- HubSpot MCP creates or updates the contact with company and role&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;create_deal&lt;/code&gt; -- HubSpot MCP creates a deal in the sales pipeline&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;post_message&lt;/code&gt; -- Slack MCP posts to #sales: "New demo request from [Company], [Role]"&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;create_event&lt;/code&gt; -- Google Calendar MCP books the meeting at the requested time&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each step uses the output of previous steps. The HubSpot contact ID from step 2 gets referenced in the deal creation in step 3. The meeting link from step 5 could be sent back through FORMLOVA's auto-reply email.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 3: NPS Feedback Loop
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;FORMLOVA + HubSpot + Slack + Linear&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An NPS survey response comes in. The LLM reads the score and branches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Score 9-10 (Promoter):
  1. Update HubSpot contact: latest_nps = 10
  2. FORMLOVA sends "Thank you" email with review request link

Score 7-8 (Passive):
  1. Update HubSpot contact: latest_nps = 7
  2. No further action

Score 0-6 (Detractor):
  1. Update HubSpot contact: latest_nps = 3
  2. Slack #cs-alert: "Detractor alert: [Name], NPS 3, reason: [verbatim]"
  3. Linear: create follow-up task assigned to CS team
  4. FORMLOVA sends "We hear you" email
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7pspurramtc7tcct0l5o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7pspurramtc7tcct0l5o.png" alt=" " width="800" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The branching logic lives in the LLM, not in FORMLOVA's workflow engine. This means the routing rules can be as nuanced as natural language allows. "If the score is below 4 AND the free-text mentions billing, route to #billing-issues instead of #cs-alert" -- that is a single sentence instruction, not a condition builder configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 4: Event Operations Pipeline
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;FORMLOVA + Google Calendar + Slack + Notion&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An event registration form receives a submission:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;get_responses&lt;/code&gt; -- pull the registration&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;create_event&lt;/code&gt; -- Google Calendar adds the event to the attendee's calendar&lt;/li&gt;
&lt;li&gt;Notion MCP adds a row to the attendee database with name, email, dietary preferences&lt;/li&gt;
&lt;li&gt;When capacity is reached, Slack gets notified: "Event X is full. 150/150 registered."&lt;/li&gt;
&lt;li&gt;Three days before the event, FORMLOVA sends reminder emails (this part is FORMLOVA-native, no MCP cross-service needed)&lt;/li&gt;
&lt;li&gt;After the event, FORMLOVA sends a follow-up survey form&lt;/li&gt;
&lt;li&gt;Survey results get summarized and posted to a Notion retrospective page&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 2-4 require cross-service orchestration. Steps 5-7 mix FORMLOVA-native automation with cross-service calls. The user does not need to know the difference.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 5: Incident Response
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;FORMLOVA + Jira + Slack + Notion + GitHub&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An incident report form captures: timestamp, severity, affected service, symptoms.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;get_responses&lt;/code&gt; -- pull the incident report&lt;/li&gt;
&lt;li&gt;Jira MCP creates a P1 ticket with all fields mapped&lt;/li&gt;
&lt;li&gt;Slack #incidents gets an alert with the Jira link and severity&lt;/li&gt;
&lt;li&gt;GitHub MCP searches recent commits for changes to the affected service&lt;/li&gt;
&lt;li&gt;If a likely culprit commit is found, GitHub MCP creates a revert branch&lt;/li&gt;
&lt;li&gt;After resolution, Notion MCP creates a postmortem page from a template with timeline, root cause, and action items pre-filled&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 4 is where this gets interesting. The LLM can correlate "auth service is returning 500s" with "commits touching src/auth/ in the last 24 hours" and surface the likely cause. It cannot always fix it, but it can narrow the search space dramatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Cannot Be Automated (Yet)
&lt;/h2&gt;

&lt;p&gt;I want to be clear about the boundary. These cross-service flows are &lt;strong&gt;chat-initiated, not event-driven.&lt;/strong&gt; The user says "process this bug report" and the LLM executes the chain. The form response does not automatically trigger the chain without human involvement.&lt;/p&gt;

&lt;p&gt;FORMLOVA's native workflow engine supports automatic triggers (response.created, capacity.reached, deadline.approaching), but those actions are limited to: send_email, update_field, and webhook. The engine cannot call Slack MCP or GitHub MCP directly, because the workflow engine is server-side code, not an MCP client.&lt;/p&gt;

&lt;p&gt;For fully automatic cross-service flows, you still need either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A webhook from FORMLOVA to a middleware (Zapier, Make, n8n) that calls the other services' APIs&lt;/li&gt;
&lt;li&gt;A polling setup where the LLM periodically checks for new responses&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest framing: MCP cross-service orchestration today is &lt;strong&gt;semi-automatic&lt;/strong&gt;. The human triggers it from chat. But the execution -- reading responses, creating issues, posting messages, opening PRs -- is fully automated once triggered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Matters for MCP Server Builders
&lt;/h2&gt;

&lt;p&gt;If you are building an MCP server, your tools do not exist in isolation. Users will connect your server alongside others and expect the LLM to chain them.&lt;/p&gt;

&lt;p&gt;This has design implications:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Return structured data, not just messages.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If your tool returns a plain-text success message, the LLM has nothing to pass to the next tool. If it returns structured data with IDs, URLs, and key fields, the LLM can reference those in subsequent calls.&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;// Bad: the LLM cannot extract the issue ID reliably&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Issue created successfully!&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// Good: the LLM can pass issue_id to the next tool&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Issue created: PROJ-142&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;issue_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PROJ-142&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://linear.app/team/PROJ-142&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;&lt;strong&gt;2. Accept flexible identifiers.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Users will paste URLs, mention names, or use partial identifiers. If your tool only accepts exact IDs, the LLM has to ask the user for the ID, breaking the flow. Accept what humans naturally provide and resolve internally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Make tools composable, not monolithic.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A single tool that "creates a contact and sends a welcome email and adds to a list" is useful in isolation but blocks cross-service composition. Separate tools for each action let the LLM interleave your tools with other services' tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Testing Reality
&lt;/h2&gt;

&lt;p&gt;Validating cross-service flows is simpler than it appears. Each MCP call is independent. If FORMLOVA-to-Slack works and Slack-to-Linear works, then FORMLOVA-to-Slack-to-Linear works. The LLM is the glue, and it handles each call the same way regardless of what came before.&lt;/p&gt;

&lt;p&gt;What you actually need to test:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does each 1:1 pair work? (Your tool output -&amp;gt; their tool input)&lt;/li&gt;
&lt;li&gt;Is your tool output structured enough for the LLM to extract what it needs?&lt;/li&gt;
&lt;li&gt;Does the LLM maintain context across the chain? (Usually yes, within a session)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What you do not need to test:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every possible N-service combination&lt;/li&gt;
&lt;li&gt;The LLM's orchestration logic (that is the client's job, not yours)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What This Means for Form Services
&lt;/h2&gt;

&lt;p&gt;Every form response is a structured data event. It has typed fields, metadata, timestamps, and context. That makes it an ideal trigger for cross-service workflows.&lt;/p&gt;

&lt;p&gt;The form service that exposes its response data well through MCP becomes a universal trigger layer. Not because it built integrations with every other service, but because it made its data accessible to an orchestrator that can talk to anything.&lt;/p&gt;

&lt;p&gt;I did not build a single integration. I built 127 tools that return structured data. The integrations build themselves every time a user connects another MCP server to their client.&lt;/p&gt;




&lt;p&gt;If you are building MCP servers and thinking about cross-service composition, I would be interested to hear your approach. The ecosystem is new enough that there are no established patterns yet.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/lovanaut55/118-mcp-tools-4-safety-levels-building-a-server-enforced-form-ops-layer-16j4"&gt;How we handle safety for 127 tools&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://formlova.com/en/signup" rel="noopener noreferrer"&gt;Get started free&lt;/a&gt; | &lt;a href="https://formlova.com/en/setup" rel="noopener noreferrer"&gt;Setup guide&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://formlova.com/en/blog/route-hot-leads-after-publish-en" rel="noopener noreferrer"&gt;Route Post-Publish Responses by Intent&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Product Hunt launch: April 15, 2026&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>mcp</category>
      <category>productivity</category>
    </item>
    <item>
      <title>FORMLOVA launches on Product Hunt April 15. If this resonated, your support means a lot: https://www.producthunt.com/products/formlova?launch=formlova</title>
      <dc:creator>Lovanaut </dc:creator>
      <pubDate>Mon, 06 Apr 2026 14:04:45 +0000</pubDate>
      <link>https://dev.to/lovanaut55/formlova-launches-on-product-hunt-april-15-if-this-resonated-your-support-means-a-lot-1be4</link>
      <guid>https://dev.to/lovanaut55/formlova-launches-on-product-hunt-april-15-if-this-resonated-your-support-means-a-lot-1be4</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/lovanaut55/118-mcp-tools-4-safety-levels-building-a-server-enforced-form-ops-layer-16j4" class="crayons-story__hidden-navigation-link"&gt;127 MCP Tools, 4 Safety Levels: Building a Server-Enforced Form Ops Layer&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/lovanaut55" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3841154%2Fcbe410ba-7007-4a46-9d11-824cfc7dd19a.png" alt="lovanaut55 profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/lovanaut55" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Lovanaut 
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Lovanaut 
                
              
              &lt;div id="story-author-preview-content-3453877" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/lovanaut55" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3841154%2Fcbe410ba-7007-4a46-9d11-824cfc7dd19a.png" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Lovanaut &lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/lovanaut55/118-mcp-tools-4-safety-levels-building-a-server-enforced-form-ops-layer-16j4" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Apr 4&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/lovanaut55/118-mcp-tools-4-safety-levels-building-a-server-enforced-form-ops-layer-16j4" id="article-link-3453877"&gt;
          127 MCP Tools, 4 Safety Levels: Building a Server-Enforced Form Ops Layer
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/ai"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;ai&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/architecture"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;architecture&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/mcp"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;mcp&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/security"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;security&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/lovanaut55/118-mcp-tools-4-safety-levels-building-a-server-enforced-form-ops-layer-16j4" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/exploding-head-daceb38d627e6ae9b730f36a1e390fca556a4289d5a41abb2c35068ad3e2c4b5.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;6&lt;span class="hidden s:inline"&gt; reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/lovanaut55/118-mcp-tools-4-safety-levels-building-a-server-enforced-form-ops-layer-16j4#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              Comments


              7&lt;span class="hidden s:inline"&gt; comments&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            6 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;



&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
      &lt;div class="c-embed__body flex items-center justify-between"&gt;
        &lt;a href="https://www.producthunt.com/products/formlova?launch=formlova" rel="noopener noreferrer" class="c-link fw-bold flex items-center"&gt;
          &lt;span class="mr-2"&gt;producthunt.com&lt;/span&gt;
          

        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


</description>
    </item>
    <item>
      <title>127 MCP Tools, 4 Safety Levels: Building a Server-Enforced Form Ops Layer</title>
      <dc:creator>Lovanaut </dc:creator>
      <pubDate>Sat, 04 Apr 2026 13:27:47 +0000</pubDate>
      <link>https://dev.to/lovanaut55/118-mcp-tools-4-safety-levels-building-a-server-enforced-form-ops-layer-16j4</link>
      <guid>https://dev.to/lovanaut55/118-mcp-tools-4-safety-levels-building-a-server-enforced-form-ops-layer-16j4</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdxr2gtdvljtzypzu07bw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdxr2gtdvljtzypzu07bw.png" alt=" " width="800" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When people hear that a product works from Claude, ChatGPT, Cursor, and other AI clients, the default assumption is: "So you added chat to your dashboard."&lt;/p&gt;

&lt;p&gt;That is not the architecture. FORMLOVA is a chat-first form service where MCP is the primary operational interface -- 127 tools across 25 categories, covering everything from form creation to response analytics to email campaigns. The dashboard exists for dense visual inspection. Chat leads for intent-to-action sequences.&lt;/p&gt;

&lt;p&gt;The interesting engineering problem is not "how do you expose tools over MCP." It is: how do you make conversation safe enough to carry real operations -- publishing live forms, sending bulk emails, deleting data -- when the LLM between you and the server will routinely ignore your instructions?&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With Prompts: LLMs Skip Steps
&lt;/h2&gt;

&lt;p&gt;Here is something I learned building this system: LLMs ignore or skip prompt instructions. You can write "ALWAYS confirm before sending email" in your system prompt. Models will skip the confirmation and call the tool directly. Not sometimes -- regularly.&lt;/p&gt;

&lt;p&gt;This is not a theoretical concern. It happened in testing across multiple models and clients. The model reads the confirmation instruction, decides it has enough context to proceed, and fires the tool with &lt;code&gt;user_confirmed=true&lt;/code&gt; on the first call.&lt;/p&gt;

&lt;p&gt;This is why server-side enforcement exists. It is not a UX choice. It is a safety requirement born from observed model behavior. If the server does not enforce confirmation, confirmation does not happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Classifying 127 Tools by Blast Radius
&lt;/h2&gt;

&lt;p&gt;Every MCP tool in the system is classified into one of four levels based on what it can break:&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;// Every tool maps to exactly one operation class and safety level&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;OperationClass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;inspect&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;prepare&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;mutate&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;external-write&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// L0: inspect   — read-only (analytics, export, list)        ~50 tools&lt;/span&gt;
&lt;span class="c1"&gt;// L1: prepare   — reversible changes (design, field edit)    ~40 tools&lt;/span&gt;
&lt;span class="c1"&gt;// L2: mutate    — affects respondents (publish, unpublish)    ~4 tools&lt;/span&gt;
&lt;span class="c1"&gt;// L3: external-write — irreversible external side effects     11 tools&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;L0 (inspect):&lt;/strong&gt; Analytics queries, CSV exports, form listings. No confirmation needed. These tools cannot change state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;L1 (prepare):&lt;/strong&gt; Design changes, field edits, settings updates. No confirmation needed either -- but every change is version-controlled, so any L1 operation can be rolled back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;L2 (mutate):&lt;/strong&gt; Publishing a form, scheduling publication. These affect respondents. The &lt;code&gt;publish_form&lt;/code&gt; tool implements a server-side review state machine that returns &lt;code&gt;next_required_action&lt;/code&gt;, &lt;code&gt;missing_requirements&lt;/code&gt;, and preview URLs on every call. The state machine advances only when real preconditions are met.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;L3 (external-write):&lt;/strong&gt; Sending emails, deleting data, removing team members. These 11 tools have irreversible external side effects. Every one requires a &lt;code&gt;confirmation_token&lt;/code&gt; before execution.&lt;/p&gt;

&lt;p&gt;The key design decision: the server decides what needs confirmation, not the LLM. L0 and L1 tools execute immediately. L2 and L3 tools enforce confirmation server-side. The INSTRUCTIONS tell the model to follow what the server returns -- nothing more.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv4sg697cw3hrjjp7ifza.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv4sg697cw3hrjjp7ifza.png" alt=" " width="800" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The confirmation_token: HMAC-SHA256 Hard Block
&lt;/h2&gt;

&lt;p&gt;For L2 and L3 operations, the server issues a cryptographic confirmation token. This is the mechanism that prevents models from bypassing safety checks:&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ConfirmationPayload&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;tool_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="c1"&gt;// bound to the specific tool being confirmed&lt;/span&gt;
  &lt;span class="nl"&gt;user_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="c1"&gt;// bound to the authenticated user&lt;/span&gt;
  &lt;span class="nl"&gt;resource_key&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="c1"&gt;// bound to the target resource (form ID, email batch, etc.)&lt;/span&gt;
  &lt;span class="nl"&gt;issued_at&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="c1"&gt;// timestamp for TTL enforcement&lt;/span&gt;
  &lt;span class="nl"&gt;scope_summary&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="c1"&gt;// human-readable description of what will happen&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Token = HMAC-SHA256(secret, JSON.stringify(payload))&lt;/span&gt;
&lt;span class="c1"&gt;// TTL: 5 minutes&lt;/span&gt;
&lt;span class="c1"&gt;// One token, one operation, one user, one resource&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The flow works like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Model calls a tool with &lt;code&gt;user_confirmed=false&lt;/code&gt; (or omits the parameter)&lt;/li&gt;
&lt;li&gt;Server returns a confirmation prompt with scope details and a &lt;code&gt;confirmation_token&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The user reviews the scope summary in their chat client&lt;/li&gt;
&lt;li&gt;Model calls the same tool with &lt;code&gt;user_confirmed=true&lt;/code&gt; and the token&lt;/li&gt;
&lt;li&gt;Server validates: HMAC signature, TTL (5 minutes), tool name match, user ID match, resource key match&lt;/li&gt;
&lt;li&gt;If valid, the operation executes. If expired or mismatched, the server returns a fresh review summary and a new token -- no hard error, no broken flow&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This design means a model cannot shortcut the confirmation. Even if it calls the tool with &lt;code&gt;user_confirmed=true&lt;/code&gt; on the first attempt, the server rejects it because there is no valid token. The token only exists after the server has shown the user what is about to happen.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff1tobjsirq43n8to9see.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff1tobjsirq43n8to9see.png" alt=" " width="800" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Preview Confirmation Uses URL Open-Tracking
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://formlova.com/en/blog/publish-review-guide-en" rel="noopener noreferrer"&gt;&lt;code&gt;publish_form&lt;/code&gt; state machine&lt;/a&gt; has a specific requirement: the user must actually look at the form preview before publication. Saying "I confirmed the preview" in chat is not sufficient. The model can generate that text without the user having seen anything.&lt;/p&gt;

&lt;p&gt;The solution: preview confirmation only advances when the preview URL is actually opened in a browser. This is a physical trigger that the LLM cannot shortcut. The server tracks whether the preview URL was visited, and &lt;code&gt;publish_form&lt;/code&gt; checks that state on every call. If both the form preview and thank-you page preview have been opened, the user can confirm both in a single message. If not, the state machine stays put.&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;// publish_form returns this on every call&lt;/span&gt;
&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;PublishReviewState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;review_state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending_preview&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;pending_requirements&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;ready&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;next_required_action&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="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;missing_requirements&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;form_preview_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;thankyou_preview_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;form_preview_opened&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;thankyou_preview_opened&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;confirmation_token&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="c1"&gt;// only present when ready&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  withFormResolver: Eliminating Chat Friction
&lt;/h2&gt;

&lt;p&gt;One early problem: every tool call required a &lt;code&gt;form_id&lt;/code&gt;, which meant users had to constantly specify which form they meant. This created unnecessary roundtrips.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;withFormResolver&lt;/code&gt; middleware auto-resolves &lt;code&gt;form_id&lt;/code&gt; when it is omitted from the tool call:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1 form in account:&lt;/strong&gt; auto-select it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiple forms:&lt;/strong&gt; prefer the most recently used form in the session (24h window)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ambiguous:&lt;/strong&gt; return a form list and let the user pick&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This middleware wraps all 65 form-scoped tools. The Zod schemas keep &lt;code&gt;form_id&lt;/code&gt; required at the type level but the middleware makes it optional at runtime, so type safety is preserved while the chat experience stays clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Dashboard Survived
&lt;/h2&gt;

&lt;p&gt;I built the dashboard form builder, then deliberately removed it. Forms are entry points, not the product. The real value is in post-publish operations: response routing, analytics, email sequences, A/B testing.&lt;/p&gt;

&lt;p&gt;But I also tested a dashboard-less mode, and it failed. When you manage dozens of forms, checking status across all of them through chat requires too many roundtrips. Chat is sequential; a dashboard is parallel.&lt;/p&gt;

&lt;p&gt;The architecture landed here: &lt;strong&gt;chat leads for intent-to-action sequences, the &lt;a href="https://formlova.com/en/blog/why-dashboard-still-exists-en" rel="noopener noreferrer"&gt;dashboard supports for dense visual inspection&lt;/a&gt;.&lt;/strong&gt; This is not a compromise. It is what the two interfaces are respectively good at.&lt;/p&gt;

&lt;p&gt;The turning point in my conviction that MCP could serve as a primary business interface came when freee and MoneyForward announced MCP-based accounting workflows. That was confirmation that MCP works for business operations beyond developer tooling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Responses to Route to Notify: Implementation Internals
&lt;/h2&gt;

&lt;p&gt;The clearest test of whether conversation is truly operational is a multi-step post-publish flow. Take a live inquiry form where respondents indicate their intent (demo request, comparing options, just browsing).&lt;/p&gt;

&lt;p&gt;From a conversational thread:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;get_response_analytics&lt;/code&gt; returns intent distribution (L0, no confirmation)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;filter_responses&lt;/code&gt; isolates high-intent respondents (L0, carries filtered set forward)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;send_filtered_email&lt;/code&gt; drafts a sales notification for those leads (L3, requires confirmation_token)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 3 is where the safety design earns its keep. The server returns the confirmation prompt with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Exact recipient count from the filtered set&lt;/li&gt;
&lt;li&gt;Email subject and body preview&lt;/li&gt;
&lt;li&gt;Sender identity&lt;/li&gt;
&lt;li&gt;A scoped &lt;code&gt;confirmation_token&lt;/code&gt; bound to this specific email batch&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The filtered set from step 2 persists across turns through the tool's resource references. The model does not need to re-query or re-filter. This carry-forward context is what makes the sequence feel like an operational workflow rather than a series of disconnected tool calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Standard I Care About
&lt;/h2&gt;

&lt;p&gt;The question is simple: can the product carry multi-step operational work through a conversational thread without losing context, scope, or trust?&lt;/p&gt;

&lt;p&gt;Trust is the hardest part. It requires that the system does not rely on LLM compliance for safety. The confirmation_token, the operation classification, the preview open-tracking -- these are all server-side mechanisms precisely because the model-side equivalent (prompt instructions) is unreliable.&lt;/p&gt;

&lt;p&gt;127 tools across 25 categories is a large surface area. Classifying every tool by blast radius and enforcing confirmation at the server level is what makes that surface area safe to expose through conversation.&lt;/p&gt;




&lt;p&gt;FORMLOVA is free to start. One inquiry form is enough to see how the safety harness and post-publish ops work in practice. If you are building MCP-first products, I would be interested to hear how you handle the confirmation problem.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://formlova.com/en/signup" rel="noopener noreferrer"&gt;Get started free&lt;/a&gt; | &lt;a href="https://formlova.com/en/setup" rel="noopener noreferrer"&gt;Setup guide&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://formlova.com/en/blog/starts-after-publish-en" rel="noopener noreferrer"&gt;Most Form Tools Stop at Creation -- FORMLOVA Starts After Publish&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://formlova.com/en/blog/route-hot-leads-after-publish-en" rel="noopener noreferrer"&gt;Route Post-Publish Responses by Intent and Send Hot Leads to Sales&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;If you want the thought piece or the founder story behind these decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://medium.com/p/b3c446cebee0" rel="noopener noreferrer"&gt;AI Should Not Stop at Making Forms&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://lovanaut.hashnode.dev/i-built-a-form-builder-then-deleted-it" rel="noopener noreferrer"&gt;I Built a Form Builder, Then Deleted It&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;FORMLOVA launches on Product Hunt April 15. If this resonated, your support means a lot: &lt;a href="https://www.producthunt.com/products/formlova?launch=formlova" rel="noopener noreferrer"&gt;https://www.producthunt.com/products/formlova?launch=formlova&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>mcp</category>
      <category>security</category>
    </item>
    <item>
      <title>Designing a CLI Skill That Structures AI Sessions into Posts -- Architecture, Security, and Implementation Decisions</title>
      <dc:creator>Lovanaut </dc:creator>
      <pubDate>Mon, 30 Mar 2026 03:08:26 +0000</pubDate>
      <link>https://dev.to/lovanaut55/designing-a-cli-skill-that-structures-ai-sessions-into-posts-architecture-security-and-2o11</link>
      <guid>https://dev.to/lovanaut55/designing-a-cli-skill-that-structures-ai-sessions-into-posts-architecture-security-and-2o11</guid>
      <description>&lt;h2&gt;
  
  
  Body
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/lovai&lt;/code&gt; is a command that structures your AI session into five blocks and posts it to &lt;a href="https://lovai.app" rel="noopener noreferrer"&gt;Lovai&lt;/a&gt;. It works with Claude Code, Cursor, Codex, and Gemini CLI.&lt;/p&gt;

&lt;p&gt;Every day, the decisions you make and the problems you hit during AI sessions vanish the moment you close the terminal. I kept losing the reasoning behind good sessions -- even when the final output looked fine, the "why I chose this over three alternatives" was gone by the next morning. I built this skill to solve that problem. This article covers how it was designed and implemented -- why this structure, where things broke, and what trade-offs I made along the way.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Full Architecture
&lt;/h3&gt;

&lt;p&gt;Here's the pipeline, end to end:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User runs /lovai
  |
  v
Step 1: Session analysis (extract 5 blocks from conversation context)
  |
  v
Step 2: Security filtering (detect and strip secrets)
  |
  v
Step 3: Block composition (auto-assign visibility levels)
  |
  v
Step 4: Metadata tagging (tool name, model, category)
  |
  v
Step 5: Preview display -&amp;gt; user confirmation
  |
  v
Step 6: Post to Lovai via API (draft or publish)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claude Code skills are defined as markdown files under &lt;code&gt;~/.claude/skills/&lt;/code&gt;. They're not code -- they're closer to behavioral instruction documents. That's precisely why design decisions matter so much here. Ambiguous instructions lead to inconsistent AI output.&lt;/p&gt;

&lt;p&gt;I'll be honest -- initially I wasn't sure a skill could handle the full pipeline from session analysis to API posting. But since Claude Code's Bash tool can run curl, the entire flow completes inside the skill with zero external dependencies. That realization is what unlocked the architecture.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Skill Definition -- 5-Block Structure
&lt;/h3&gt;

&lt;p&gt;The core of the skill is deciding what to extract from a session. This is where I spent the most time iterating.&lt;/p&gt;

&lt;p&gt;Here's the actual instruction in the skill's markdown definition:&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="gu"&gt;### Step 1: Analyze Session&lt;/span&gt;

Extract from the current conversation context:
&lt;span class="p"&gt;
1.&lt;/span&gt; &lt;span class="gs"&gt;**Core Insight**&lt;/span&gt;: Most important outcome/decision (1-2 sentences)
&lt;span class="p"&gt;2.&lt;/span&gt; &lt;span class="gs"&gt;**Why**&lt;/span&gt;: Why this approach was chosen
&lt;span class="p"&gt;3.&lt;/span&gt; &lt;span class="gs"&gt;**Gotchas**&lt;/span&gt;: Unexpected problems and how they were solved
&lt;span class="p"&gt;4.&lt;/span&gt; &lt;span class="gs"&gt;**Code Details**&lt;/span&gt;: Key code changes, configs, commands
&lt;span class="p"&gt;5.&lt;/span&gt; &lt;span class="gs"&gt;**Learnings**&lt;/span&gt;: Tips for next time
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These five extraction targets map to Lovai's block sections:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Block 1: Core Insight  -&amp;gt; section: "insight"
Block 2: Approach      -&amp;gt; section: "why"
Block 3: Gotchas       -&amp;gt; section: "how"
Block 4: Code Details  -&amp;gt; section: "detail"
Block 5: Learnings     -&amp;gt; section: "tips"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Why Exactly Five
&lt;/h4&gt;

&lt;p&gt;I started with three blocks (overview, details, takeaway). The problem was that "why I chose this approach" and "where things broke" got tangled together in "details," making the output hard to parse.&lt;/p&gt;

&lt;p&gt;When I pushed it to seven or eight blocks, the output became inconsistent. AI tends to force-fill every block, even when there isn't enough substance. You end up with padding.&lt;/p&gt;

&lt;p&gt;Five blocks hit the sweet spot -- enough granularity to reconstruct the decision-making process, not so many that the AI starts hallucinating content. Separating &lt;strong&gt;Why&lt;/strong&gt; and &lt;strong&gt;Gotchas&lt;/strong&gt; into independent blocks was the key decision. Finished code is reproducible by anyone. But "why I rejected the alternatives" and "where I got stuck unexpectedly" -- only the person who was there can write those.&lt;/p&gt;

&lt;h4&gt;
  
  
  The Section Attribute Mistake
&lt;/h4&gt;

&lt;p&gt;Lovai posts use a &lt;code&gt;section&lt;/code&gt; attribute to label each block's semantic role:&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;"blockType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"privacy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"public"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"section"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"why"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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 valid values are &lt;code&gt;insight&lt;/code&gt;, &lt;code&gt;why&lt;/code&gt;, &lt;code&gt;how&lt;/code&gt;, &lt;code&gt;tips&lt;/code&gt;, and &lt;code&gt;detail&lt;/code&gt;. They correspond to Lovai's post structure labels on the UI.&lt;/p&gt;

&lt;p&gt;Here's where I tripped up: I initially assumed &lt;code&gt;section&lt;/code&gt; was a free-form string and set it to things like &lt;code&gt;"core-insight"&lt;/code&gt;. Lovai's API accepted the request without error, but the post UI silently dropped the block labels. No error, no warning -- just missing labels. It took me an embarrassingly long time to figure out. The fix was reading the API spec properly, which I should have done from the start.&lt;/p&gt;




&lt;h3&gt;
  
  
  Security Filtering -- The Two-Layer Defense
&lt;/h3&gt;

&lt;p&gt;Session logs almost certainly contain API keys, tokens, and credentials. Posting those publicly would be a disaster. Security filtering was the first thing I designed, not an afterthought.&lt;/p&gt;

&lt;h4&gt;
  
  
  The Actual Secret Detection Patterns
&lt;/h4&gt;

&lt;p&gt;The defense is two layers deep. First, the skill's markdown instructions tell the AI explicitly: "Never include .env contents. Strip API keys and tokens." But relying solely on AI instructions felt insufficient for something this critical.&lt;/p&gt;

&lt;p&gt;So the second layer runs server-side when Lovai's API receives the post. Here are the actual regex patterns:&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;SECRET_PATTERNS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;RegExp&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="sr"&gt;/sk-lovai-&lt;/span&gt;&lt;span class="se"&gt;[\w&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="sr"&gt;/sk-proj-&lt;/span&gt;&lt;span class="se"&gt;[\w&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// OpenAI&lt;/span&gt;
  &lt;span class="sr"&gt;/sk-ant-&lt;/span&gt;&lt;span class="se"&gt;[\w&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// Anthropic&lt;/span&gt;
  &lt;span class="sr"&gt;/sk_live_&lt;/span&gt;&lt;span class="se"&gt;[\w]&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;// Stripe&lt;/span&gt;
  &lt;span class="sr"&gt;/sk-&lt;/span&gt;&lt;span class="se"&gt;[\w]{20,}&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// Generic keys&lt;/span&gt;
  &lt;span class="sr"&gt;/ghp_&lt;/span&gt;&lt;span class="se"&gt;[\w]&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;// GitHub PAT&lt;/span&gt;
  &lt;span class="sr"&gt;/AKIA&lt;/span&gt;&lt;span class="se"&gt;[\w]&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;// AWS&lt;/span&gt;
  &lt;span class="sr"&gt;/eyJ&lt;/span&gt;&lt;span class="se"&gt;[\w&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="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;eyJ&lt;/span&gt;&lt;span class="se"&gt;[\w&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="se"&gt;\.[\w&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// JWT&lt;/span&gt;
  &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\w&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="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;SECRET|KEY|TOKEN|PASSWORD&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;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\S&lt;/span&gt;&lt;span class="sr"&gt;+/gi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// .env format&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything matched gets replaced with &lt;code&gt;[REDACTED]&lt;/code&gt; before storage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Before
const apiKey = "sk-proj-abc123def456ghi789";
const dbUrl = "postgres://user:password@localhost:5432/mydb";

// After
const apiKey = "[REDACTED]";
const dbUrl = "[REDACTED]";
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Absolute file paths also get converted to relative paths (&lt;code&gt;src/lib/...&lt;/code&gt;). Absolute paths leak usernames and directory structures -- a subtle but real exposure.&lt;/p&gt;

&lt;h4&gt;
  
  
  Where I Got It Wrong
&lt;/h4&gt;

&lt;p&gt;I'll be candid about a mistake. The &lt;code&gt;SECRET|KEY|TOKEN|PASSWORD&lt;/code&gt; pattern in the regex was too aggressive. It caught &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; environment variables -- values that are intentionally public and safe to share. On the flip side, custom-prefixed secrets like &lt;code&gt;MYAPP_SECRET_KEY&lt;/code&gt; with unusual patterns could still slip through.&lt;/p&gt;

&lt;p&gt;The current approach combines pattern matching for candidate detection with the AI's contextual understanding for final judgment. The skill instruction says "never include .env file contents," and the AI uses conversation context to assess whether something is actually sensitive. Pattern matching alone has precision limits, so there's an intentional reliance on the AI's comprehension layer too.&lt;/p&gt;

&lt;p&gt;It's not perfect. But the worst-case scenario -- accidentally posting a production API key -- is substantially harder to hit.&lt;/p&gt;




&lt;h3&gt;
  
  
  Multi-Tool Support: Unified Analysis Over Tool-Specific Parsers
&lt;/h3&gt;

&lt;p&gt;Claude Code, Cursor, Codex, Gemini CLI -- each structures session context differently.&lt;/p&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;Session Format&lt;/th&gt;
&lt;th&gt;Skill Approach&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;td&gt;Conversation context passed directly to skill&lt;/td&gt;
&lt;td&gt;Direct analysis&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cursor&lt;/td&gt;
&lt;td&gt;In-editor conversation log&lt;/td&gt;
&lt;td&gt;Read as context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Codex&lt;/td&gt;
&lt;td&gt;CLI-based session&lt;/td&gt;
&lt;td&gt;Analyze from conversation context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini CLI&lt;/td&gt;
&lt;td&gt;Unique dialog format&lt;/td&gt;
&lt;td&gt;Analyze from conversation context&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The design question was: build dedicated parsers for each tool, or unify on a generic analysis approach?&lt;/p&gt;

&lt;p&gt;I went with &lt;strong&gt;unified analysis&lt;/strong&gt;. Two reasons.&lt;/p&gt;

&lt;p&gt;First, skills are markdown-based instructions, not code. The more complex the branching logic, the more the AI's interpretation drifts. Natural language instructions don't handle conditional complexity well.&lt;/p&gt;

&lt;p&gt;Second, the essential structure of a session is the same regardless of tool. "What were you trying to accomplish?" "What did you try?" "What didn't work?" "What did you learn?" These four elements don't change between Claude Code and Cursor.&lt;/p&gt;

&lt;p&gt;That said, a &lt;code&gt;client&lt;/code&gt; field in &lt;code&gt;config.json&lt;/code&gt; lets users specify their tool. This is for metadata tagging on the post, not for branching the analysis logic:&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;"apiKey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sk-lovai-xxxxx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://lovai.app/api/posts/external-create"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"defaultLanguage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"client"&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-code"&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;h3&gt;
  
  
  API Integration: Why curl, Why No SDK
&lt;/h3&gt;

&lt;p&gt;The skill posts to Lovai via curl. I considered building an SDK (npm package), but dropped the idea. The reason: the skill's execution environment is Bash. Claude Code skills are markdown instructions -- there's no way to import Node.js modules. curl runs directly from the Bash tool. Zero dependencies, zero maintenance burden.&lt;/p&gt;

&lt;h4&gt;
  
  
  Setup
&lt;/h4&gt;

&lt;p&gt;Create a Lovai account. Generate an API key from the settings page. Paste the setup command into your CLI. That's it.&lt;/p&gt;

&lt;p&gt;The API key is stored at &lt;code&gt;~/.claude/skills/lovai/config.json&lt;/code&gt;. Since &lt;code&gt;~/.claude/&lt;/code&gt; is user-local, it never gets committed to a repository.&lt;/p&gt;

&lt;h4&gt;
  
  
  The API Call
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ENDPOINT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$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;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "title": "LP funnel redesign from AI brainstorming session",
    "primaryLanguage": "en",
    "blocks": [
      {
        "blockType": "text",
        "privacy": "public",
        "section": "insight",
        "content": "Testing 3 LP funnels revealed that problem-solution-pricing outperforms feature-list-pricing..."
      },
      ...
    ],
    "tools": ["claude-code"],
    "models": ["claude-sonnet-4"],
    "category": "marketing",
    "purpose": "session-capture",
    "publish": false
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;publish: false&lt;/code&gt; is the default. Posts are always created as drafts. You review and edit on Lovai before publishing.&lt;/p&gt;

&lt;p&gt;I debated whether to default to publish or draft. One-command publishing is convenient, but session logs are close to raw data. The security filter runs, but I can't guarantee 100% accuracy. Defaulting to draft was the safe-side call.&lt;/p&gt;

&lt;h4&gt;
  
  
  Beyond Lovai
&lt;/h4&gt;

&lt;p&gt;The structured output doesn't have to stay on Lovai. Use it as the foundation for a Dev.to article, a blog post, or just keep it as a personal work log. The value is in capturing the session while context is fresh -- where it ends up is your call.&lt;/p&gt;




&lt;h3&gt;
  
  
  Visibility Auto-Assignment
&lt;/h3&gt;

&lt;p&gt;Lovai posts support per-block visibility: &lt;code&gt;public&lt;/code&gt; (anyone), &lt;code&gt;premium&lt;/code&gt; (paid access via Stripe Connect), and &lt;code&gt;private&lt;/code&gt; (you only).&lt;/p&gt;

&lt;p&gt;The skill auto-assigns based on these criteria:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;public&lt;/strong&gt;: Conceptual explanations, high-level reasoning, gotcha overviews&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;premium&lt;/strong&gt;: Reusable code snippets, config files, concrete data and templates&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;private&lt;/strong&gt;: Not set by the skill (users can change this on Lovai)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This comes from my own experience as a content buyer. "Why did you choose this approach?" -- I want that for free. "Here's the exact implementation" -- that's worth paying for.&lt;/p&gt;




&lt;h3&gt;
  
  
  A Real Output: Remotion Video Session
&lt;/h3&gt;

&lt;p&gt;Here's what a 3-hour Remotion implementation session produced:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Core Insight&lt;/strong&gt; -- CSS transitions turned out lighter and more controllable than spring animations inside Remotion's pipeline&lt;br&gt;
&lt;strong&gt;Why&lt;/strong&gt; -- framer-motion conflicted with Remotion's rendering pipeline&lt;br&gt;
&lt;strong&gt;Gotchas&lt;/strong&gt; -- Misunderstood the relationship between &lt;code&gt;fps: 30&lt;/code&gt; and &lt;code&gt;durationInFrames&lt;/code&gt; -- a 2-second animation rendered as 4 seconds&lt;br&gt;
&lt;strong&gt;Details&lt;/strong&gt; -- [premium]&lt;br&gt;
&lt;strong&gt;Learnings&lt;/strong&gt; -- Don't mix external animation libraries with Remotion; stay within its native API surface&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Five blocks, one to two lines each. Three hours of trial and error compressed to this granularity.&lt;/p&gt;




&lt;h3&gt;
  
  
  Three Design Principles in Hindsight
&lt;/h3&gt;

&lt;p&gt;Building this skill surfaced a few things I think are generally applicable:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skills are behavioral instructions, not code.&lt;/strong&gt; Ambiguous instructions produce inconsistent AI output. Defining the 5-block structure explicitly was about guaranteeing output reproducibility. The more precisely you specify what you want, the less the AI drifts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security must be safe by default.&lt;/strong&gt; Draft-first posting, automatic secret stripping, relative path conversion. Any design that relies on users remembering to be careful will eventually cause an incident. The defense has to be structural, not behavioral.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-tool support should focus on commonalities, not differences.&lt;/strong&gt; Building per-tool parsers was tempting. But once I recognized that every session shares the same essential structure -- intent, attempts, failures, takeaways -- the unified approach became both simpler and more maintainable.&lt;/p&gt;

&lt;p&gt;There's still plenty to improve. Security filter accuracy and block structure customization are the obvious next targets. But the core problem -- sessions disappearing into the void -- is solved.&lt;/p&gt;

&lt;p&gt;Try it at &lt;a href="https://lovai.app" rel="noopener noreferrer"&gt;lovai.app&lt;/a&gt;.&lt;/p&gt;




&lt;h3&gt;
  
  
  Related Articles
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://dev.to/lovanaut55/stripe-closed-my-connect-account-heres-what-actually-fixed-it-in-24-hours-66j"&gt;Stripe Closed My Connect Account -- Here's What Actually Fixed It in 24 Hours&lt;/a&gt; -- The payment infrastructure behind Lovai's creator payouts, and what happens when Stripe pulls the rug&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/lovanaut55/openrouter-structured-output-broke-before-translation-quality-did-3-layers-of-defense-for-1cdb"&gt;OpenRouter Structured Output Broke Before Translation Quality Did&lt;/a&gt; -- Another case where the "boring" engineering (output structure, fallback layers) matters more than the AI itself&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>cli</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Anthropic Proved AI Can't Evaluate Its Own Work. Here's How I Rebuilt My Claude Code Setup Around That.</title>
      <dc:creator>Lovanaut </dc:creator>
      <pubDate>Fri, 27 Mar 2026 04:01:38 +0000</pubDate>
      <link>https://dev.to/lovanaut55/anthropic-proved-ai-cant-evaluate-its-own-work-heres-how-i-rebuilt-my-claude-code-setup-around-5f8i</link>
      <guid>https://dev.to/lovanaut55/anthropic-proved-ai-cant-evaluate-its-own-work-heres-how-i-rebuilt-my-claude-code-setup-around-5f8i</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmv8fyos3ckjitzavekwl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmv8fyos3ckjitzavekwl.png" alt=" " width="800" height="395"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I've been building products with Claude Code for months. Every time I asked "is this implementation correct?", the answer was "yes, it's properly implemented." Every time. Even when the code had bugs that broke in production.&lt;/p&gt;

&lt;p&gt;Then Anthropic published a blog post that explained exactly why. I mapped my setup against their findings, and realized: &lt;strong&gt;my evaluator layer was almost empty.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's how I rebuilt it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Jump to:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What Anthropic's experiment showed&lt;/li&gt;
&lt;li&gt;Mapping this to Claude Code&lt;/li&gt;
&lt;li&gt;Layer 1: Rules — always-on review criteria&lt;/li&gt;
&lt;li&gt;Layer 2: Skills — on-demand reviewers&lt;/li&gt;
&lt;li&gt;Layer 3: Agent separation — who builds vs who reviews&lt;/li&gt;
&lt;li&gt;3 principles for evaluation design&lt;/li&gt;
&lt;li&gt;Final file structure&lt;/li&gt;
&lt;li&gt;Harness design checklist&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What Anthropic's experiment showed
&lt;/h2&gt;

&lt;p&gt;In March 2026, Anthropic published &lt;a href="https://www.anthropic.com/engineering/harness-design-long-running-apps" rel="noopener noreferrer"&gt;"Harness design for long-running apps"&lt;/a&gt; — experiments where AI agents autonomously built apps over multi-hour sessions.&lt;/p&gt;

&lt;p&gt;The headline finding:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Agents asked to evaluate their own work tend to confidently praise it, even when it's clearly mediocre to human observers.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It gets worse. Agents would spot real problems, then wave them off as unimportant and approve anyway. They'd skim instead of testing edge cases. Anthropic themselves put it bluntly: "Claude is an inadequate QA agent out of the box."&lt;/p&gt;

&lt;p&gt;Their fix was splitting generation from evaluation — three agents:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Planner&lt;/td&gt;
&lt;td&gt;Expands a one-line prompt into a full product spec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generator&lt;/td&gt;
&lt;td&gt;Writes the code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Evaluator&lt;/td&gt;
&lt;td&gt;Clicks through the running app, finds bugs, scores against criteria&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The difference was stark:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Solo (no evaluator)&lt;/td&gt;
&lt;td&gt;20 min&lt;/td&gt;
&lt;td&gt;$9&lt;/td&gt;
&lt;td&gt;Core features broken&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full harness (with evaluator)&lt;/td&gt;
&lt;td&gt;6 hrs&lt;/td&gt;
&lt;td&gt;$200&lt;/td&gt;
&lt;td&gt;Basic functionality worked + AI features&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The evaluator checked 27 criteria per sprint and filed bug reports like "&lt;code&gt;fillRectangle&lt;/code&gt; exists but doesn't fire on &lt;code&gt;mouseUp&lt;/code&gt;."&lt;/p&gt;

&lt;p&gt;Reading this, it clicked: &lt;strong&gt;Claude Code's config system can give you the same split.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Mapping to Claude Code
&lt;/h2&gt;

&lt;p&gt;Here's the mapping:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Anthropic's agent&lt;/th&gt;
&lt;th&gt;Claude Code equivalent&lt;/th&gt;
&lt;th&gt;What goes here&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Planner&lt;/td&gt;
&lt;td&gt;CLAUDE.md + planning skills&lt;/td&gt;
&lt;td&gt;Project context, constraints, design rationale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generator&lt;/td&gt;
&lt;td&gt;Claude Code + technical skills&lt;/td&gt;
&lt;td&gt;Code generation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Evaluator&lt;/td&gt;
&lt;td&gt;Review agents + rules + hooks&lt;/td&gt;
&lt;td&gt;Quality gates, automated checks&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When I laid out my own setup this way, the gap was obvious. CLAUDE.md had project context. Skills had coding patterns. But &lt;strong&gt;nothing was actually checking whether the output was correct.&lt;/strong&gt; I was asking the generator to review its own work — the exact failure mode Anthropic documented.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer 1: Rules — always-on review criteria
&lt;/h2&gt;

&lt;p&gt;Files in &lt;code&gt;~/.claude/rules/&lt;/code&gt; load every session. Put things here that AI won't do on its own but that matter in production.&lt;/p&gt;

&lt;p&gt;Here's a taste of my Supabase/PostgreSQL rules (30 total):&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;# ~/.claude/rules/supabase-postgres.md&lt;/span&gt;

&lt;span class="gu"&gt;## Index FK columns&lt;/span&gt;
PostgreSQL does NOT auto-index foreign key columns.

&lt;span class="gu"&gt;## RLS performance&lt;/span&gt;
Wrap auth.uid() in SELECT to prevent per-row execution:
&lt;span class="p"&gt;-&lt;/span&gt; BAD: using (user_id = auth.uid())
&lt;span class="p"&gt;-&lt;/span&gt; GOOD: using (user_id = (select auth.uid()))

&lt;span class="gu"&gt;## Cursor-based pagination&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; BAD: OFFSET (slow on deep pages)
&lt;span class="p"&gt;-&lt;/span&gt; GOOD: WHERE id &amp;gt; $last_id ORDER BY id LIMIT 20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without this rule, the AI generates &lt;code&gt;auth.uid()&lt;/code&gt; without the SELECT wrapper every time. Works fine with small tables. Tests pass. Then production slows to a crawl as rows grow. Classic "surface-level test that misses the deeper bug" — exactly what Anthropic described.&lt;/p&gt;

&lt;p&gt;I think of rules as "never step on this mine again" files. Every rule started as a real mistake.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer 2: Skills — on-demand reviewers
&lt;/h2&gt;

&lt;p&gt;Rules load every session, so too many will eat your context window. Anything task-specific goes into skills (&lt;code&gt;.claude/skills/&lt;/code&gt;), which show only their titles by default and load on demand.&lt;/p&gt;

&lt;p&gt;You can wire up keyword-triggered activation:&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;# ~/.claude/CLAUDE.md (excerpt)&lt;/span&gt;

&lt;span class="gu"&gt;## Automatic Skill Activation&lt;/span&gt;

&lt;span class="gu"&gt;### Testing / TDD&lt;/span&gt;
&lt;span class="gs"&gt;**Trigger:**&lt;/span&gt; test, TDD, coverage, unit test
&lt;span class="gs"&gt;**Action:**&lt;/span&gt; Run test-driven-development skill

&lt;span class="gu"&gt;### Bug / Error handling&lt;/span&gt;
&lt;span class="gs"&gt;**Trigger:**&lt;/span&gt; bug, error, debug, broken
&lt;span class="gs"&gt;**Action:**&lt;/span&gt; Run systematic-debugging skill

&lt;span class="gu"&gt;### Completion check&lt;/span&gt;
&lt;span class="gs"&gt;**Trigger:**&lt;/span&gt; done, verify, review, check
&lt;span class="gs"&gt;**Action:**&lt;/span&gt; Run verification-before-completion skill
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The completion check is the big one. In Anthropic's experiment, the evaluator ran checks at the end of each sprint. Same idea here — say "verify this" and a quality checklist kicks in behind the scenes.&lt;/p&gt;

&lt;p&gt;I run 27 skills total. 7 auto-activate on keywords.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer 3: Agent separation — who builds vs who reviews
&lt;/h2&gt;

&lt;p&gt;The core insight from Anthropic: separate the builder from the reviewer. In Claude Code, you do this through agent orchestration:&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;# ~/.claude/rules/agents.md&lt;/span&gt;

&lt;span class="gu"&gt;## Role: Manager&lt;/span&gt;
You are a manager and agent orchestrator.

&lt;span class="gs"&gt;**Rules:**&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Never implement directly — delegate all implementation to Sub Agents
&lt;span class="p"&gt;-&lt;/span&gt; Break tasks into small units and run PDCA cycles

&lt;span class="gu"&gt;## Delegation&lt;/span&gt;
&lt;span class="gu"&gt;### Always delegate:&lt;/span&gt;
&lt;span class="p"&gt;1.&lt;/span&gt; Code implementation
&lt;span class="p"&gt;2.&lt;/span&gt; Debugging
&lt;span class="p"&gt;3.&lt;/span&gt; Test creation

&lt;span class="gu"&gt;### Manager handles directly:&lt;/span&gt;
&lt;span class="p"&gt;1.&lt;/span&gt; Task decomposition
&lt;span class="p"&gt;2.&lt;/span&gt; Progress verification
&lt;span class="p"&gt;3.&lt;/span&gt; Plan adjustment
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trick: pin the main Claude as "manager = reviewer" and push all implementation to Sub Agents. This gives Claude Code the same generator/evaluator split that Anthropic proved works.&lt;/p&gt;

&lt;p&gt;Main Claude doesn't write code. It reviews what Sub Agents produce. Planner + evaluator = main Claude. Generator = Sub Agents.&lt;/p&gt;




&lt;h2&gt;
  
  
  3 principles for evaluation design
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Weight your criteria toward what AI misses
&lt;/h3&gt;

&lt;p&gt;Anthropic's frontend experiment used 4 criteria: design quality, originality, technical execution, functionality. They weighted design and originality higher — because the AI already did well on technical execution and functionality.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lower the weight on things AI handles naturally. Raise it on things AI overlooks.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Low weight (AI's fine):&lt;/strong&gt; syntactically correct code, basic API endpoints&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High weight (AI misses):&lt;/strong&gt; performance traps (&lt;code&gt;auth.uid()&lt;/code&gt; without SELECT), UX decisions that affect conversion, security blind spots&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Prune your harness as models improve
&lt;/h3&gt;

&lt;p&gt;From Anthropic:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Every component of a harness encodes an assumption about what the model can't do alone, and these assumptions can be wrong or quickly become outdated as models improve.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When they went from Opus 4.5 to 4.6, sprint decomposition became unnecessary — the newer model handled long sessions on its own. Same thing happens with your Claude Code rules. Something essential today may be dead weight next quarter.&lt;/p&gt;

&lt;p&gt;I've revised my CLAUDE.md 8 times. The test for each line: "If I delete this, will the AI make mistakes?" No? Cut it.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Better models mean more harness possibilities, not fewer
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;The space of interesting harness combinations expands rather than contracts as models improve.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Sounds backwards, but it matches my experience. As the AI gets smarter, you can delegate more. But the remaining evaluation points get more nuanced, more subtle, and higher-stakes. The easy rules go away. The hard judgment calls stay.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final file structure
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~/.claude/
├── CLAUDE.md                # Planner layer (project overview + skill triggers)
├── rules/
│   ├── agents.md            # Generator/evaluator split
│   ├── supabase-postgres.md # Review criteria (DB/RLS, 30 rules)
│   ├── react-nextjs.md      # Review criteria (React/Next.js)
│   ├── security.md          # Review criteria (security)
│   ├── coding-style.md      # Code quality
│   └── testing.md           # Test quality
└── skills/                   # On-demand reviewers
    ├── verification-before-completion  # End-of-task checks
    ├── systematic-debugging            # Debug-time checks
    ├── test-driven-development         # TDD enforcement
    └── ... (27 total)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;How it maps to Anthropic's architecture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CLAUDE.md&lt;/strong&gt; → Planner artifact (the product spec)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;rules/&lt;/strong&gt; → Review criteria (the sprint contract)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;skills/&lt;/strong&gt; → Specialist reviewers (activate when needed)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;agents.md&lt;/strong&gt; → Builder/reviewer separation&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Harness design checklist
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Planner layer (CLAUDE.md)&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Project overview and constraints documented&lt;/li&gt;
&lt;li&gt;[ ] Design decisions and their rationale included&lt;/li&gt;
&lt;li&gt;[ ] Under 200 lines (bloated CLAUDE.md degrades instruction-following)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Review criteria (rules/)&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Cover what AI misses, not what it already does well&lt;/li&gt;
&lt;li&gt;[ ] Weighted toward production-critical concerns&lt;/li&gt;
&lt;li&gt;[ ] Regularly pruned as models improve&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On-demand reviewers (skills/)&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Separated by domain (DB, React, security, etc.)&lt;/li&gt;
&lt;li&gt;[ ] Keyword auto-activation configured&lt;/li&gt;
&lt;li&gt;[ ] Completion verification skill exists&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Builder/reviewer separation (agents.md)&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Main Claude delegates implementation to Sub Agents&lt;/li&gt;
&lt;li&gt;[ ] Verification flow for implementation results&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Anthropic's original post:&lt;/strong&gt; &lt;a href="https://www.anthropic.com/engineering/harness-design-long-running-apps" rel="noopener noreferrer"&gt;Harness design for long-running apps&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude Code docs:&lt;/strong&gt; &lt;a href="https://docs.anthropic.com/en/docs/claude-code" rel="noopener noreferrer"&gt;docs.anthropic.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Japanese version (Zenn):&lt;/strong&gt; &lt;a href="https://zenn.dev/lova_man/articles/99777e473b3c2c" rel="noopener noreferrer"&gt;https://zenn.dev/lova_man/articles/99777e473b3c2c&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>testing</category>
      <category>tooling</category>
    </item>
    <item>
      <title>OpenRouter Structured Output Broke Before Translation Quality Did — 3 Layers of Defense for Production</title>
      <dc:creator>Lovanaut </dc:creator>
      <pubDate>Wed, 25 Mar 2026 04:35:08 +0000</pubDate>
      <link>https://dev.to/lovanaut55/openrouter-structured-output-broke-before-translation-quality-did-3-layers-of-defense-for-1cdb</link>
      <guid>https://dev.to/lovanaut55/openrouter-structured-output-broke-before-translation-quality-did-3-layers-of-defense-for-1cdb</guid>
      <description>&lt;p&gt;The first production incident wasn't a bad translation. It was a Markdown code fence wrapping the JSON response.&lt;/p&gt;

&lt;p&gt;One day, error notifications flooded in. The UI was rendering blank blocks where translations should have been. The cause? The model had quietly started being "helpful" by wrapping its JSON responses in &lt;code&gt;&lt;/code&gt;&lt;code&gt;json ...&lt;/code&gt;&lt;code&gt;&lt;/code&gt; fences. &lt;code&gt;JSON.parse()&lt;/code&gt; choked immediately, and the translation feature went down — not because of bad translations, but because of three backticks.&lt;/p&gt;

&lt;p&gt;This article walks through the exact defense system I built to stabilize structured output from the OpenRouter API in production, in the order the failures surfaced. The main topic is malformed JSON responses. I also cover retry/fallback and language detection, but JSON handling is where most of the engineering hours went.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Core Issue:&lt;/strong&gt; LLM translation quality doesn't matter if &lt;code&gt;JSON.parse()&lt;/code&gt; fails. Markdown code fences and truncation will break your app before bad translations do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Safe Baseline:&lt;/strong&gt; &lt;code&gt;json_object&lt;/code&gt; + &lt;code&gt;response-healing&lt;/code&gt; + fail-closed parsing + expected-key validation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Fix:&lt;/strong&gt; A 3-layer defense using &lt;code&gt;response_format: { type: 'json_object' }&lt;/code&gt;, OpenRouter's &lt;code&gt;response-healing&lt;/code&gt; plugin, and a custom defensive parser that rejects partial or malformed data instead of returning incomplete results.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bonus:&lt;/strong&gt; Why you should only retry HTTP 429/5xx errors, and why binary language detection fails for tech content.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure mode&lt;/th&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Defense&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Code fences&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;JSON.parse()&lt;/code&gt; fails&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;json_object&lt;/code&gt; + &lt;code&gt;response-healing&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Missing keys&lt;/td&gt;
&lt;td&gt;Blank UI blocks&lt;/td&gt;
&lt;td&gt;Fail-closed parser + expected-key validation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;429 / 5xx&lt;/td&gt;
&lt;td&gt;Intermittent request failure&lt;/td&gt;
&lt;td&gt;Retry + model fallback double loop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mixed-language text&lt;/td&gt;
&lt;td&gt;Wasted API calls or false skips&lt;/td&gt;
&lt;td&gt;Ratio-based detection with asymmetric thresholds&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Context:&lt;/strong&gt; This comes from building auto-translation (Japanese to English, bidirectional) for &lt;a href="https://lovai.app" rel="noopener noreferrer"&gt;Lovai&lt;/a&gt;, an AI recipe-sharing platform. The translation handles user-generated posts with titles, summaries, and multi-block body content.&lt;/p&gt;

&lt;p&gt;This article focuses on in-app content translation — it's a separate layer from &lt;code&gt;hreflang&lt;/code&gt;-based multilingual SEO.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Fixing LLM JSON Corruption: 3-Layer Defense with OpenRouter
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The first thing you need to handle when you run an LLM API in production is not translation quality — it's malformed JSON responses.&lt;/strong&gt; Bad quality means "the translation is awkward." Parse failure means "the feature is down."&lt;/p&gt;

&lt;p&gt;In my initial implementation, I wasn't using JSON Mode at all. The system prompt just said "return JSON," with no &lt;code&gt;response_format&lt;/code&gt; specified. This worked fine for a while — until the model started wrapping responses in Markdown code fences without warning.&lt;/p&gt;

&lt;p&gt;Here's what was actually coming back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;```json
{"__title": "Built Translation with OpenRouter"}
```
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;JSON.parse()&lt;/code&gt; chokes on this immediately. I added three layers of defense.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 1: Enable JSON Mode with &lt;code&gt;response_format: { type: 'json_object' }&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;First, I added &lt;code&gt;response_format: { type: 'json_object' }&lt;/code&gt;. This constrains the model to return valid JSON. &lt;strong&gt;Running LLM output through &lt;code&gt;JSON.parse()&lt;/code&gt; without &lt;code&gt;response_format&lt;/code&gt; is not safe for production.&lt;/strong&gt; Prompt-only instructions break silently when models update or when the service is under load.&lt;/p&gt;

&lt;p&gt;Note that structured outputs (&lt;code&gt;json_object&lt;/code&gt; / &lt;code&gt;json_schema&lt;/code&gt;) are only available on supported models. OpenRouter's model pages list compatibility.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 2: OpenRouter &lt;code&gt;response-healing&lt;/code&gt; Plugin for Auto-Repair
&lt;/h3&gt;

&lt;p&gt;OpenRouter has a &lt;a href="https://openrouter.ai/docs/guides/features/plugins/response-healing" rel="noopener noreferrer"&gt;response-healing plugin&lt;/a&gt; that automatically fixes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Markdown code fence removal (&lt;code&gt;&lt;/code&gt;&lt;code&gt;json ...&lt;/code&gt;&lt;code&gt;&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Missing brackets and trailing commas&lt;/li&gt;
&lt;li&gt;JSON extraction from surrounding text
&lt;/li&gt;
&lt;/ul&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;requestBody&lt;/span&gt; &lt;span class="o"&gt;=&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;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;response_format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;json_object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;plugins&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;response-healing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;  &lt;span class="c1"&gt;// Enable auto-repair&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;&lt;code&gt;response-healing&lt;/code&gt; works alongside &lt;code&gt;json_object&lt;/code&gt; / &lt;code&gt;json_schema&lt;/code&gt;.&lt;/strong&gt; Known constraints: it's non-streaming only, and it can't fix truncation from &lt;code&gt;max_tokens&lt;/code&gt; cutoff.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 3: Fail-Closed Parsing — Parse Success Is Not Enough
&lt;/h3&gt;

&lt;p&gt;Even with JSON Mode and &lt;code&gt;response-healing&lt;/code&gt;, I keep a defensive parser on the application side. It's insurance against model behavior changes or API spec updates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The parser rejects partial or malformed data instead of returning incomplete results.&lt;/strong&gt; If it can't produce valid, complete JSON with all expected keys, it throws rather than silently serving broken translations.&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;function&lt;/span&gt; &lt;span class="nf"&gt;parseTranslationResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;expectedKeys&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="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="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Step 1: Try parsing raw content directly&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&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="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="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;content&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="c1"&gt;// Step 2: Strip code fences and retry&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^``&lt;/span&gt;&lt;span class="err"&gt;`
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;endraw&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;(?:&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;)?&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="sr"&gt;/gm, ''&lt;/span&gt;&lt;span class="err"&gt;)
&lt;/span&gt;      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;^
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="s2"&gt;```\s*/gm, '')
      .trim();
    parsed = JSON.parse(stripped);  // Let it throw if still invalid
  }

  // Step 3: Validate structure — fail closed
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
    throw new Error('LLM response is not a JSON object');
  }

  const result: Record&amp;lt;string, string&amp;gt; = {};
  for (const key of expectedKeys) {
    const value = (parsed as Record&amp;lt;string, unknown&amp;gt;)[key];
    if (typeof value !== 'string') {
      throw new Error(`&lt;/span&gt;&lt;span class="nx"&gt;Missing&lt;/span&gt; &lt;span class="nx"&gt;or&lt;/span&gt; &lt;span class="nx"&gt;non&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`);
    }
    result[key] = value;
  }

  // Log unexpected keys (model occasionally adds metadata fields)
  const extraKeys = Object.keys(parsed as Record&amp;lt;string, unknown&amp;gt;)
    .filter(k =&amp;gt; !expectedKeys.includes(k));
  if (extraKeys.length &amp;gt; 0) {
    console.warn(`&lt;/span&gt;&lt;span class="nx"&gt;LLM&lt;/span&gt; &lt;span class="nx"&gt;returned&lt;/span&gt; &lt;span class="nx"&gt;unexpected&lt;/span&gt; &lt;span class="na"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;extraKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`);
  }

  return result;
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key design decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fail closed:&lt;/strong&gt; Missing keys throw an error rather than silently returning partial data. This routes failures to the retry loop instead of serving broken translations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key validation:&lt;/strong&gt; The caller passes &lt;code&gt;expectedKeys&lt;/code&gt; (block IDs), and the parser verifies every expected key is present with a string value. This catches cases where &lt;code&gt;JSON.parse()&lt;/code&gt; succeeds but the model dropped or renamed keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extra key warning:&lt;/strong&gt; Unexpected keys get logged but don't fail the request — the model occasionally adds metadata fields that are harmless.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Stabilizing JSON output from LLM APIs requires both API-side constraints (&lt;code&gt;json_object&lt;/code&gt; + &lt;code&gt;response-healing&lt;/code&gt;) and application-side defensive parsing with key validation.&lt;/strong&gt; Either one alone leaves you exposed to model behavior drift or API spec changes.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;When to use &lt;code&gt;json_schema&lt;/code&gt; instead:&lt;/strong&gt; OpenRouter also supports &lt;code&gt;json_schema&lt;/code&gt; mode. With &lt;code&gt;json_schema&lt;/code&gt; + &lt;code&gt;strict: true&lt;/code&gt;, you get output that matches a predefined schema. For translation, the keys are dynamic (they depend on block IDs per post), so &lt;code&gt;json_object&lt;/code&gt; is simpler. &lt;strong&gt;If your keys are static and predictable — like entity extraction (person names, organizations, dates as fixed fields) — &lt;code&gt;json_schema&lt;/code&gt; + &lt;code&gt;strict: true&lt;/code&gt; is more reliable, and Layer 1 alone may be sufficient.&lt;/strong&gt; That said, you can approximate dynamic keys with &lt;code&gt;json_schema&lt;/code&gt; by generating the schema per request or using &lt;code&gt;additionalProperties&lt;/code&gt; — it's just more implementation overhead.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  LLM API Retry Design: Model Fallback and HTTP Error Strategy
&lt;/h2&gt;

&lt;p&gt;After JSON parsing, the next thing I needed to stabilize was API call reliability.&lt;/p&gt;

&lt;h3&gt;
  
  
  Model Fallback x Retry Double Loop
&lt;/h3&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;MODEL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;google/gemini-3-flash-preview&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;FALLBACK_MODEL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OPENROUTER_TRANSLATE_FALLBACK_MODEL&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;modelCandidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&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="nx"&gt;FALLBACK_MODEL&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&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;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;model&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;modelCandidates&lt;/span&gt;&lt;span class="p"&gt;)&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;let&lt;/span&gt; &lt;span class="nx"&gt;attempt&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;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;maxRetry&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Translation attempt&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 outer loop switches models. The inner loop handles retries. If the primary model exhausts all retries (max 2), it falls through to the fallback model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In my implementation, I only retry HTTP 429 and 5xx responses.&lt;/strong&gt; Retrying a 400 (bad request) or 401 (auth error) won't change the outcome — you end up in an infinite retry loop on a config error.&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;shouldRetryStatus&lt;/span&gt; &lt;span class="o"&gt;=&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="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note: this covers HTTP-level errors. Transport-level failures (timeouts, connection resets, DNS failures) are handled separately by the AbortController timeout below — those always get retried since they don't indicate a permanent problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  Timeout Control with AbortController
&lt;/h3&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;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AbortController&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;timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&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;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;OPENROUTER_API_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&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;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timeout&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;I initially set it to 8 seconds, but longer posts (5,000+ characters) were timing out mid-flight. I bumped it to 15 seconds. &lt;strong&gt;LLM API timeouts need to scale with input length.&lt;/strong&gt; Too short and you kill legitimate requests. Too long and you can't detect hangs.&lt;/p&gt;




&lt;h2&gt;
  
  
  Language Detection for Mixed-Script Text: Why Ratio Beats Binary
&lt;/h2&gt;

&lt;p&gt;Before translating, you need to check: "Is this text already in the target language?" This prevents unnecessary API calls. I got this wrong in the first version too.&lt;/p&gt;

&lt;p&gt;The initial approach was simple binary detection — "does the text contain characters of this language?"&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;// Initial implementation (broken)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hasJapanese&lt;/span&gt; &lt;span class="o"&gt;=&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="nx"&gt;boolean&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\u&lt;/span&gt;&lt;span class="sr"&gt;3040-&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;30ff&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;3400-&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;9fff&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/u&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;text&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;hasLatin&lt;/span&gt; &lt;span class="o"&gt;=&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="nx"&gt;boolean&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z&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="nf"&gt;test&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="c1"&gt;// Has Japanese &amp;amp;&amp;amp; no Latin → Japanese text → skip translation&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;isJa&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;isEn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* skip */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pattern applies to any language pair where technical terminology bleeds across scripts. A Japanese post like "Next.jsでReactアプリを作った" contains both Japanese characters and Latin characters, so binary detection flags it for translation.&lt;/p&gt;

&lt;p&gt;In tech content, English terms mixed into non-Latin-script text is the norm, not the exception. This isn't limited to Japanese-English — Korean tech posts with English terms, Chinese posts with API names, Arabic text with framework names all hit the same trap. Any language pair where one script dominates but technical terms intrude from another will produce false positives with binary detection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Binary detection classified nearly every Japanese post as "needs translation," triggering a flood of unnecessary API calls.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fix was ratio-based detection.&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;function&lt;/span&gt; &lt;span class="nf"&gt;isAlreadyInTargetLanguage&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="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SupportedLanguage&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;jaChars&lt;/span&gt; &lt;span class="o"&gt;=&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="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\u&lt;/span&gt;&lt;span class="sr"&gt;3040-&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;30ff&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;3400-&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;9fff&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;ff00-&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;ffef&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/gu&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
  &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&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;latChars&lt;/span&gt; &lt;span class="o"&gt;=&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="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nx"&gt;length&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;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jaChars&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;latChars&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;total&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&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;jaRatio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jaChars&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;total&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;target&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ja&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;jaRatio&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&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;target&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;jaRatio&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&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 threshold started at 70%, but Japanese text with 30%+ English terms was being misclassified as "already English." I raised it to 95%. After this change, false skips (Japanese text that didn't get translated) dropped to near zero.&lt;/p&gt;

&lt;p&gt;Detection is split into two functions with different jobs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Function&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Threshold&lt;/th&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;isAlreadyInTargetLanguage()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Filter out text that doesn't need translation&lt;/td&gt;
&lt;td&gt;95%&lt;/td&gt;
&lt;td&gt;Strict (if in doubt, translate)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;isLikelyInTargetLanguage()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Validate translation output quality&lt;/td&gt;
&lt;td&gt;25%+ or 8+ chars&lt;/td&gt;
&lt;td&gt;Lenient (tolerate mixed terminology)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;For text where technical terms cross script boundaries, "skip detection" should be strict and "output validation" should be lenient. This asymmetry matters.&lt;/strong&gt; Flip it and you either miss texts that need translation, or reject perfectly good translations.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why OpenRouter + LLM over Google Translate or DeepL
&lt;/h2&gt;

&lt;p&gt;I evaluated Google Cloud Translation API, DeepL API, and OpenRouter + LLM. Here's why I chose OpenRouter.&lt;/p&gt;

&lt;p&gt;The dealbreaker was &lt;strong&gt;control over the response structure&lt;/strong&gt;. Posts on the platform have titles, summaries, and multiple body blocks, each with a unique ID. Google Translate and DeepL can batch-translate, but they return an array — you have to track which translation maps to which block by index position yourself.&lt;/p&gt;

&lt;p&gt;With LLM translation, I can use block IDs as JSON keys. The response comes back with those same keys, values translated. No mapping logic needed.&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;// Input: block IDs as keys&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;__title&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;Built Translation with OpenRouter&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;block_abc_text&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;Body text...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// Output: keys preserved, values translated&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;__title&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;OpenRouterで翻訳を作った&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;block_abc_text&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;本文テキスト...&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;This "send keyed JSON, get keyed JSON back" pattern works well specifically because of OpenRouter's &lt;code&gt;json_object&lt;/code&gt; mode combined with the &lt;code&gt;response-healing&lt;/code&gt; plugin.&lt;/p&gt;

&lt;p&gt;On cost (at the time of writing): translating one post (~2,000 chars / ~1,500 tokens) costs about $0.04 on Google NMT vs. under $0.001 on &lt;code&gt;gemini-2.5-flash-lite&lt;/code&gt; (input $0.10 / output $0.40 per 1M tokens). Character-based and token-based pricing don't compare directly, but for short-form content translation, the LLM route is significantly cheaper.&lt;/p&gt;

&lt;p&gt;Detailed comparison: Google Cloud Translation / DeepL / OpenRouter&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Factor&lt;/th&gt;
&lt;th&gt;Google Cloud Translation / DeepL&lt;/th&gt;
&lt;th&gt;OpenRouter + LLM&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Response structure&lt;/td&gt;
&lt;td&gt;Array of translated strings in input order. Map translations back to fields by index&lt;/td&gt;
&lt;td&gt;Send JSON with keys, get JSON back with keys preserved and only values translated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Terminology control&lt;/td&gt;
&lt;td&gt;Glossary (pre-registered term mappings). Precise, but requires manual registration&lt;/td&gt;
&lt;td&gt;Prompt-level instructions. No pre-registration, less strict&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model switching&lt;/td&gt;
&lt;td&gt;Limited. Google has NMT vs Translation LLM. DeepL has &lt;code&gt;model_type&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Change one environment variable. &lt;code&gt;gemini-2.5-flash-lite&lt;/code&gt; to &lt;code&gt;gemini-3-flash-preview&lt;/code&gt; without changing application code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pricing&lt;/td&gt;
&lt;td&gt;Per-character (Google NMT: $20/1M chars, 500K free/month. DeepL Free: 500K chars/month)&lt;/td&gt;
&lt;td&gt;Per-token (varies by model)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For terminology control, Google/DeepL Glossaries are more precise. But in AI/tech content, new terms appear constantly. Registering each one gets expensive in maintenance time. With LLM translation, I just tell the prompt "preserve technical terms as-is." Less strict, but that simplicity matters when you're a solo developer.&lt;/p&gt;

&lt;p&gt;OpenRouter lets you call multiple LLM models through a unified API. I started with &lt;code&gt;gemini-2.5-flash-lite&lt;/code&gt; and later switched to &lt;code&gt;gemini-3-flash-preview&lt;/code&gt; — just an environment variable change, without changing application code.&lt;/p&gt;




&lt;h2&gt;
  
  
  Timeline: How This Feature Actually Evolved
&lt;/h2&gt;

&lt;p&gt;This feature didn't ship complete. It improved every time production broke.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Changes&lt;/th&gt;
&lt;th&gt;What Broke&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;v1 (Jan)&lt;/td&gt;
&lt;td&gt;Basic implementation. &lt;code&gt;gemini-2.5-flash-lite&lt;/code&gt;, 8s timeout, binary language detection, no JSON Mode&lt;/td&gt;
&lt;td&gt;Initial release&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v1.1 (Jan)&lt;/td&gt;
&lt;td&gt;Explicit prompt rules (preserve technical terms, preserve keys, etc.)&lt;/td&gt;
&lt;td&gt;Technical terms getting translated unintentionally&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v2 (Feb)&lt;/td&gt;
&lt;td&gt;JSON Mode added, &lt;code&gt;response-healing&lt;/code&gt; enabled, defensive parser, model switch (&lt;code&gt;gemini-3-flash-preview&lt;/code&gt;), ratio-based language detection (70%), timeout bumped to 15s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;JSON parse errors flooding error notifications&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v3 (Current)&lt;/td&gt;
&lt;td&gt;Language detection threshold raised to 95%, output validation function separated, key validation added&lt;/td&gt;
&lt;td&gt;70% threshold causing "Japanese text not translated" misclassifications&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Getting from v1 ("it works") to v2 ("it doesn't break") took the most effort.&lt;/strong&gt; The v2 JSON corruption fix alone took a full day.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three Principles for Stable Structured LLM Output
&lt;/h2&gt;

&lt;p&gt;These are the three principles that stabilized LLM structured output in my production system:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;JSON corruption is priority zero.&lt;/strong&gt; &lt;code&gt;response_format: { type: 'json_object' }&lt;/code&gt; + &lt;code&gt;response-healing&lt;/code&gt; plugin handles most cases, but keep an application-side defensive parser with key validation for &lt;code&gt;max_tokens&lt;/code&gt; truncation and model differences. &lt;strong&gt;If your schema is static, &lt;code&gt;json_schema&lt;/code&gt; + &lt;code&gt;strict: true&lt;/code&gt; is more reliable.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only retry HTTP 429 and 5xx.&lt;/strong&gt; Retrying 4xx is pointless. Handle transport-level failures (timeouts, connection resets) separately. Separate model fallback and retry into a double loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use ratio-based language detection.&lt;/strong&gt; Binary detection is useless when technical terms cross script boundaries. Make "skip detection" strict and "output validation" lenient — the asymmetry is the point.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;These three principles aren't specific to translation.&lt;/strong&gt; They apply to any case where you expect structured output from an LLM API — text classification, entity extraction, content structuring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you're parsing JSON from an LLM in production, treat malformed output as an uptime problem, not a quality problem.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you've hit a different failure mode with structured LLM output, I'd like to hear about it.&lt;/p&gt;




&lt;h3&gt;
  
  
  References
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://openrouter.ai/docs" rel="noopener noreferrer"&gt;OpenRouter API Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://openrouter.ai/docs/guides/features/plugins/response-healing" rel="noopener noreferrer"&gt;OpenRouter response-healing Plugin&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://openrouter.ai/docs/guides/features/structured-outputs" rel="noopener noreferrer"&gt;OpenRouter Structured Outputs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://lovai.app" rel="noopener noreferrer"&gt;Lovai - AI Recipe Sharing Platform&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>api</category>
      <category>architecture</category>
      <category>javascript</category>
      <category>llm</category>
    </item>
    <item>
      <title>Stripe Closed My Connect Account. Here's What Actually Fixed It in 24 Hours.</title>
      <dc:creator>Lovanaut </dc:creator>
      <pubDate>Wed, 25 Mar 2026 03:56:05 +0000</pubDate>
      <link>https://dev.to/lovanaut55/stripe-closed-my-connect-account-heres-what-actually-fixed-it-in-24-hours-66j</link>
      <guid>https://dev.to/lovanaut55/stripe-closed-my-connect-account-heres-what-actually-fixed-it-in-24-hours-66j</guid>
      <description>&lt;p&gt;I'm building &lt;a href="https://lovai.app" rel="noopener noreferrer"&gt;Lovai&lt;/a&gt;, a creator marketplace where users can sell parts of a post as paid content — block by block. To support payouts, I implemented Stripe Connect Express with hosted onboarding, destination charges, webhook-based purchase fulfillment, and Supabase RLS for access control.&lt;/p&gt;

&lt;p&gt;During review, Stripe temporarily closed the account and flagged the business as potential aggregation. Stripe later told me that, in my case, the Connect application had not been fully submitted at the time of review. After I clarified the business model and completed the application, the account was re-reviewed and approved the next day.&lt;/p&gt;

&lt;p&gt;This post explains what happened, what fixed it, and how the payment flow works end to end.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Jump to:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Why Stripe flagged my platform as aggregation&lt;/li&gt;
&lt;li&gt;Stripe Connect Express: onboarding implementation&lt;/li&gt;
&lt;li&gt;Checkout with destination charges&lt;/li&gt;
&lt;li&gt;Webhook signature verification and idempotency&lt;/li&gt;
&lt;li&gt;Purchase records secured with Supabase RLS&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Accepting payments vs. routing payouts: a different beast
&lt;/h2&gt;

&lt;p&gt;There are plenty of tutorials on adding Stripe to your SaaS for subscription billing. Building a system where &lt;em&gt;your users&lt;/em&gt; sell their own content and receive payouts is a fundamentally different problem.&lt;/p&gt;

&lt;p&gt;On Lovai, creators can mark parts of their posts as paid. When someone buys, the creator gets paid directly. To make this work, I needed Stripe Connect.&lt;/p&gt;

&lt;p&gt;If you're just collecting payments for yourself, a standard Stripe account is fine. The moment you route money to other people, you're dealing with compliance reviews, legal requirements, and fund flow design.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why I chose Stripe Connect Express over Standard or Custom
&lt;/h2&gt;

&lt;p&gt;Stripe Connect offers multiple account types.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Stripe now describes Standard / Express / Custom account types as deprecated for newer integrations, while existing integrations continue to work. For new builds, check &lt;a href="https://docs.stripe.com/connect/accounts" rel="noopener noreferrer"&gt;controller properties or newer migration paths&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I went with Express for one reason: &lt;strong&gt;Stripe handles identity verification and onboarding for you.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When a creator opens a Stripe account, they need to verify their identity and register a bank account. Building those screens yourself is a massive time sink. With Express, Stripe provides the entire onboarding flow — a huge win for solo developers.&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;account&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;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accounts&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;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;JP&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;card_payments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;requested&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;transfers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;requested&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="na"&gt;business_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;individual&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;h2&gt;
  
  
  Legal requirements before setting up Stripe Connect
&lt;/h2&gt;

&lt;p&gt;This gets overlooked surprisingly often. In practice, Stripe's review team expected these pages to be live on my site before approval:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Privacy Policy&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Terms of Service&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;E-commerce disclosure page&lt;/strong&gt; — In Japan, this is required under the Act on Specified Commercial Transactions (特定商取引法). It's prescriptive: you must publish your seller name, address, return policy, and pricing. Other countries have analogous requirements.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This isn't Connect-specific. It's a prerequisite for accepting any payments. Get these pages live before you start the Connect application.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Heads up:&lt;/strong&gt; Stripe's review team flagged the title of my disclosure page. The exact wording needed to match the legally prescribed format. They also required my seller name and responsible person to match my Stripe account registration exactly. Small details, but they'll hold up your review.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Why Stripe flagged my platform as aggregation
&lt;/h2&gt;

&lt;p&gt;When the "Your account has been closed" email arrived, I froze. I had just finished wiring up the Supabase RLS policies. The platform was ready. And then Stripe shut the account down.&lt;/p&gt;

&lt;p&gt;I'd previously integrated Stripe Connect for another project (Sapolova, a creator support platform), and that review went smoothly. The business model was simpler, and the review was straightforward.&lt;/p&gt;

&lt;p&gt;Lovai was different. &lt;strong&gt;Stripe closed my account.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What Stripe told me
&lt;/h3&gt;

&lt;p&gt;The email said Lovai's business fell under "aggregation" — one of their &lt;a href="https://stripe.com/legal/restricted-businesses" rel="noopener noreferrer"&gt;prohibited business categories&lt;/a&gt;. They noted that review criteria are confidential and couldn't share further details.&lt;/p&gt;

&lt;h3&gt;
  
  
  How I responded
&lt;/h3&gt;

&lt;p&gt;I sent a detailed breakdown of Lovai's business model:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Clarified the service category&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Educational Services: sharing technical workflows&lt;/li&gt;
&lt;li&gt;Digital Goods: code snippets, prompts, AI recipes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;2. Demonstrated content moderation&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Terms of service explicitly ban adult content, copyright infringement, and get-rich-quick schemes&lt;/li&gt;
&lt;li&gt;Moderation systems are in place&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;3. Referenced comparable services&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Named platforms with similar business models that use Stripe — to show that the model fit an established pattern Stripe already supports&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What actually triggered the review
&lt;/h3&gt;

&lt;p&gt;I also proposed an alternative: Lovai as the sole seller, with monthly bank transfers to creators instead of Connect.&lt;/p&gt;

&lt;p&gt;Stripe's response revealed the actual issue. In my case, &lt;strong&gt;the Connect application hadn't been fully submitted at the time of review, so they interpreted it as "intending to process third-party payments without Connect."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;They further explained that a marketplace operating &lt;em&gt;without&lt;/em&gt; Connect would constitute aggregation — a prohibited activity. The alternative I'd proposed would have been the actual violation.&lt;/p&gt;

&lt;p&gt;Once my Connect application was confirmed as submitted, they re-reviewed the account. &lt;strong&gt;The next day, Stripe approved the business on re-review.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This was my specific experience. The definition of "aggregation" and review criteria are at Stripe's discretion and may vary by service.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Three things I'd do differently
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Submit the Connect application early.&lt;/strong&gt; Stripe may interpret a gap between account creation and Connect application submission as intent to process third-party payments without Connect — which can qualify as aggregation. In my case, that gap was what triggered the flag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prepare your explanation before you need it.&lt;/strong&gt; When a service spans multiple categories (education, digital goods, creator economy), Stripe's review team needs to map it to an established pattern. Organize your service category, comparable platforms, and content policies so the reviewer can classify quickly. The harder it is to categorize, the more likely it gets flagged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't accept rejection as final.&lt;/strong&gt; A detailed, structured explanation can get you a re-review. The key is making it easy for the reviewer to say "yes" — show that your model fits a pattern Stripe already supports, not that you're doing something novel.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The complete payment flow
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sequenceDiagram
    participant Buyer
    participant Lovai as Lovai (Server)
    participant Stripe
    participant Creator as Creator's Stripe Account

    Buyer-&amp;gt;&amp;gt;Lovai: Purchase paid content
    Lovai-&amp;gt;&amp;gt;Lovai: Validation (already purchased? own post?)
    Lovai-&amp;gt;&amp;gt;Stripe: Create Checkout Session (with transfer_data)
    Stripe--&amp;gt;&amp;gt;Buyer: Redirect to payment page
    Buyer-&amp;gt;&amp;gt;Stripe: Enter card details &amp;amp; pay
    Stripe-&amp;gt;&amp;gt;Lovai: Webhook (checkout.session.completed)
    Lovai-&amp;gt;&amp;gt;Lovai: Verify signature → update purchase record → log earnings
    Stripe-&amp;gt;&amp;gt;Creator: Auto-transfer (amount minus fees)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When creating the Checkout Session, you specify the creator's Stripe account in &lt;code&gt;transfer_data&lt;/code&gt;. This creates a &lt;a href="https://docs.stripe.com/connect/destination-charges" rel="noopener noreferrer"&gt;destination charge&lt;/a&gt;: Stripe processes the charge on the platform account, routes funds to the creator's connected account, returns the application fee to the platform, and debits Stripe processing fees from the platform balance.&lt;/p&gt;




&lt;h2&gt;
  
  
  Stripe Connect Express: onboarding implementation
&lt;/h2&gt;

&lt;p&gt;This is the flow for creators to set up their Stripe Connect account.&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createConnectAccount&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;userId&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;getAuthUserId&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Check for existing account (prevent duplicates)&lt;/span&gt;
  &lt;span class="kd"&gt;const&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="nx"&gt;existingAccount&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;serviceClient&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;creator_stripe_accounts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe_account_id, details_submitted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;maybeSingle&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;accountId&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existingAccount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;accountId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;existingAccount&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stripe_account_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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;account&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;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accounts&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;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;JP&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;card_payments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;requested&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;transfers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;requested&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="na"&gt;business_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;individual&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lovai_user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;accountId&lt;/span&gt; &lt;span class="o"&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;id&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;serviceClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;creator_stripe_accounts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;stripe_account_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;charges_enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;payouts_enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;details_submitted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="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;accountLink&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;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accountLinks&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;account&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;refresh_url&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="nx"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/settings/payments?refresh=true`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;return_url&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="nx"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/settings/payments?success=true`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;account_onboarding&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;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;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;accountLink&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="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;Storing the Lovai user ID in &lt;code&gt;metadata&lt;/code&gt; lets you identify which user owns which Stripe account. The &lt;code&gt;charges_enabled&lt;/code&gt;, &lt;code&gt;payouts_enabled&lt;/code&gt;, and &lt;code&gt;details_submitted&lt;/code&gt; statuses are synced from the Stripe API to your local DB.&lt;/p&gt;




&lt;h2&gt;
  
  
  Checkout with destination charges (transfer_data)
&lt;/h2&gt;

&lt;p&gt;This handles what happens when a buyer purchases paid content. Lovai uses Stripe's hosted Checkout — redirecting to their payment page.&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createPurchaseCheckoutSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;postId&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;userId&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;getAuthUserId&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;post&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;getPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;postId&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;post&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;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NOT_FOUND&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;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;has_premium&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price_yen&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;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NOT_PREMIUM&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;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;author_id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="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="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OWN_POST&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;existingPurchase&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;getExistingPurchase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existingPurchase&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;completed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ALREADY_PURCHASED&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;creatorAccount&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;getCreatorStripeAccount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;author_id&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;creatorAccount&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;charges_enabled&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;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CREATOR_NOT_READY&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;priceAmount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price_yen&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;applicationFee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculatePlatformFee&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;priceAmount&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;session&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;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;checkout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessions&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;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;payment&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;payment_method_types&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;card&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;line_items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="na"&gt;price_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;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jpy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;product_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;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Premium content purchase&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;unit_amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;priceAmount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;quantity&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="na"&gt;payment_intent_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;application_fee_amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;applicationFee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;transfer_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;destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;creatorAccount&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stripe_account_id&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;success_url&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="nx"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/post/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?purchase=success`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;cancel_url&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="nx"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/post/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?purchase=cancelled`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;post_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;buyer_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;author_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;author_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;price_yen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;priceAmount&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;await&lt;/span&gt; &lt;span class="nx"&gt;serviceClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;post_purchases&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;post_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;buyer_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;price_yen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;priceAmount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;stripe_checkout_session_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;session&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pending&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;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;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;session&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="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;Three parameters that matter:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;What breaks without it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;transfer_data.destination&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Routes funds to the creator's Stripe account&lt;/td&gt;
&lt;td&gt;Payment succeeds but creator never gets paid&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;application_fee_amount&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your platform fee — Stripe deducts this and sends it to you&lt;/td&gt;
&lt;td&gt;You earn nothing from the transaction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;metadata&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Identifies which post was purchased and by whom&lt;/td&gt;
&lt;td&gt;Webhook can't update the right purchase record&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;CREATOR_NOT_READY&lt;/code&gt; check matters.&lt;/strong&gt; Specifying &lt;code&gt;transfer_data&lt;/code&gt; when the creator's Stripe account isn't active causes an API error. Lovai caches &lt;code&gt;charges_enabled&lt;/code&gt; locally but re-checks via the Stripe API before creating each checkout session. Cache alone would miss cases where Stripe deactivated an account.&lt;/p&gt;




&lt;h2&gt;
  
  
  Webhook signature verification and idempotency
&lt;/h2&gt;

&lt;p&gt;The webhook receives payment completion events from Stripe and updates purchase records.&lt;/p&gt;

&lt;h3&gt;
  
  
  Signature verification (non-negotiable)
&lt;/h3&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&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;NextRequest&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;body&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&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;signature&lt;/span&gt; &lt;span class="o"&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;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&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;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&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="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;webhookSecret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&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="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid signature&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;400&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Never skip signature verification.&lt;/strong&gt; Without it, anyone can send fake webhook requests and manipulate purchase records. Similarly, &lt;code&gt;SUPABASE_SERVICE_ROLE_KEY&lt;/code&gt; is server-only — never include it in client-side code.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Three-layer idempotency
&lt;/h3&gt;

&lt;p&gt;Stripe webhooks can deliver the same event multiple times. This implementation prevents duplicate processing with three layers: event deduplication, purchase status check, and earnings duplicate prevention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 1: Event deduplication via unique constraint&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stripe_webhook_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;generated&lt;/span&gt; &lt;span class="n"&gt;always&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;identity&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;event_id&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;event_type&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;processed_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If two webhooks for the same event arrive simultaneously, only the first insert succeeds. The second hits the unique constraint violation — no race condition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 2: Purchase status check&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existingPurchase&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;completed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&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="na"&gt;received&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Layer 3: Earnings duplicate prevention&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createCreatorEarning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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;purchaseId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;creatorId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;grossAmount&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;params&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="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;creator_earnings&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;purchase_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;purchaseId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;maybeSingle&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;existing&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;platformFee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculatePlatformFee&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;grossAmount&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;stripeFee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculateStripeFee&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;grossAmount&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;netAmount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;grossAmount&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;platformFee&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;stripeFee&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;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;creator_earnings&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;creator_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;creatorId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;purchase_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;purchaseId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;post_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;gross_amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;grossAmount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;platform_fee&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;platformFee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;stripe_fee&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;stripeFee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;net_amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;netAmount&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Storing &lt;code&gt;gross_amount&lt;/code&gt;, &lt;code&gt;platform_fee&lt;/code&gt;, &lt;code&gt;stripe_fee&lt;/code&gt;, and &lt;code&gt;net_amount&lt;/code&gt; separately means you can trace exactly which formula produced each record when you adjust fees later.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;For simplicity, this shows sequential operations. In production, if the DB update fails after the event is logged, you get an inconsistent state. Ideally, wrap event logging, purchase update, and earnings creation in a single transaction. If that's not feasible, use a recoverable job queue.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Earnings calculation
&lt;/h3&gt;

&lt;p&gt;For a 500 JPY (~$3.50 USD) purchase:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Amount&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sale price (gross)&lt;/td&gt;
&lt;td&gt;500 JPY&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stripe processing fee&lt;/td&gt;
&lt;td&gt;3.6% for domestic cards in Japan (&lt;a href="https://stripe.com/jp/pricing" rel="noopener noreferrer"&gt;Stripe pricing&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Platform fee&lt;/td&gt;
&lt;td&gt;Based on your rate (set via &lt;code&gt;application_fee_amount&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Creator payout (net)&lt;/td&gt;
&lt;td&gt;Sale price - Stripe fee - platform fee&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;On Stripe fees:&lt;/strong&gt; The 3.6% rate applies to domestic card payments in Japan. Stripe also sets minimum transaction amounts that vary by currency — check the &lt;a href="https://docs.stripe.com/currencies#minimum-and-maximum-charge-amounts" rel="noopener noreferrer"&gt;Stripe docs&lt;/a&gt; for current limits. Fees and minimums differ by payment method (cards, convenience store payments, bank transfers). Always verify actual amounts in your Stripe Dashboard.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Purchase records secured with Supabase RLS
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Table definition
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;purchase_status&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;enum&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'completed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'refunded'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;post_purchases&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;post_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;restrict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;buyer_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;cascade&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;price_yen&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;stripe_payment_intent_id&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;stripe_checkout_session_id&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;purchase_status&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;completed_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;unique&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;buyer_id&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;Design decisions:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;unique (post_id, buyer_id)&lt;/code&gt;&lt;/strong&gt; — Prevents double-purchasing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;on delete restrict&lt;/code&gt;&lt;/strong&gt; — Prevents creators from deleting purchased posts. Buyers paid for that content; it shouldn't vanish. Creators can change a post's status instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  RLS: users can read, only the server can write
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"post_purchases_select_own"&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;post_purchases&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buyer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"post_purchases_select_author"&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;post_purchases&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
      &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;post_purchases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;post_id&lt;/span&gt;
        &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;author_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;INSERT, UPDATE, and DELETE are not permitted for any user.&lt;/strong&gt; All mutations go through the webhook via the Service Role client.&lt;/p&gt;

&lt;p&gt;If UPDATE were open, a user could flip their purchase from &lt;code&gt;pending&lt;/code&gt; to &lt;code&gt;completed&lt;/code&gt; without paying. I covered this in detail in my article on &lt;a href="https://zenn.dev/lova_man/articles/bf2f5d6c3d2a75" rel="noopener noreferrer"&gt;how Lovai uses Supabase RLS to protect paid content at the database layer&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Access check with Security Definer
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="k"&gt;replace&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;has_purchased&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;p_post_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;p_user_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt;
&lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt;
&lt;span class="k"&gt;security&lt;/span&gt; &lt;span class="k"&gt;definer&lt;/span&gt;
&lt;span class="k"&gt;stable&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;search_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;
&lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;post_purchases&lt;/span&gt;
    &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;post_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_post_id&lt;/span&gt;
      &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;buyer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_user_id&lt;/span&gt;
      &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'completed'&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;set search_path = ''&lt;/code&gt; plus fully qualified table names (&lt;code&gt;public.post_purchases&lt;/code&gt;) prevents schema injection attacks. Always apply both when creating Security Definer functions.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd tell someone building a creator marketplace
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;What to get right&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Before Stripe&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Legal pages live. Seller info matches Stripe registration exactly.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Connect application&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Submit it at the same time as account creation. A gap between the two was what triggered my review issue.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;If flagged&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Prepare a structured explanation: service category, comparable platforms, content policies. Make it easy for the reviewer.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Implementation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;transfer_data.destination&lt;/code&gt; is everything. Without it, creators don't get paid.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Security&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Webhook signature verification is mandatory. Users must never have write access to purchase records. Three-layer idempotency. Security Definer functions need &lt;code&gt;set search_path = ''&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you're building something where users earn money through your platform, the payment architecture is the one thing you can't get wrong. I hope this saves you some of the headaches I went through.&lt;/p&gt;

&lt;p&gt;What's the worst surprise you've hit during a Stripe integration? I'd love to hear about it in the comments.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt; &lt;a href="https://zenn.dev/lova_man/articles/bf2f5d6c3d2a75" rel="noopener noreferrer"&gt;How Lovai uses Supabase RLS to protect paid content at the database layer&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try Lovai:&lt;/strong&gt; &lt;a href="https://lovai.app" rel="noopener noreferrer"&gt;AI recipes and dev workflows, shared block by block&lt;/a&gt;&lt;/p&gt;

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