<?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: Keo Fung | FormLM</title>
    <description>The latest articles on DEV Community by Keo Fung | FormLM (@keofung).</description>
    <link>https://dev.to/keofung</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3945809%2F0e192d77-8846-4fe8-9653-d66d2dba8cbb.png</url>
      <title>DEV Community: Keo Fung | FormLM</title>
      <link>https://dev.to/keofung</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/keofung"/>
    <language>en</language>
    <item>
      <title>When Your AI Duplicates Every Field: A Story About Idempotency</title>
      <dc:creator>Keo Fung | FormLM</dc:creator>
      <pubDate>Fri, 24 Jul 2026 08:55:00 +0000</pubDate>
      <link>https://dev.to/keofung/when-your-ai-duplicates-every-field-a-story-about-idempotency-3jj6</link>
      <guid>https://dev.to/keofung/when-your-ai-duplicates-every-field-a-story-about-idempotency-3jj6</guid>
      <description>&lt;p&gt;I had a form with 12 fields. Then my Claude Desktop session crashed. I restarted it, asked Claude to "continue building the form," and ended up with 24 fields. Half of them were exact duplicates — same IDs, same titles, same types. The form rendered with two "Email" fields, two "Name" fields, and two of every radio button.&lt;/p&gt;

&lt;p&gt;This is the story of how &lt;code&gt;field_add&lt;/code&gt; isn't idempotent, why that matters when an AI is driving, and the pattern I adopted to fix it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update (v0.2.0)&lt;/strong&gt;: The &lt;code&gt;field_add&lt;/code&gt; command is now accessed via &lt;code&gt;formlm_exec&lt;/code&gt;. The &lt;code&gt;formlm_generate&lt;/code&gt; smart pipeline eliminates this entire class of problem — the server-side AssessAgent handles field creation atomically and idempotently. The find-before-add pattern remains useful when using &lt;code&gt;formlm_exec&lt;/code&gt; for manual field operations.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What Happened
&lt;/h2&gt;

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

&lt;ol&gt;
&lt;li&gt;I asked Claude to build a customer satisfaction survey with 12 fields&lt;/li&gt;
&lt;li&gt;Claude created the app, added 8 fields, and then the session timed out&lt;/li&gt;
&lt;li&gt;I started a new session and said "continue building the customer satisfaction survey"&lt;/li&gt;
&lt;li&gt;Claude called &lt;code&gt;app_list&lt;/code&gt;, found the existing app, and started adding fields again&lt;/li&gt;
&lt;li&gt;It re-added all 12 fields — including the 8 that were already there&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result was a form with 20 fields total (8 original + 12 duplicate). The duplicate field IDs were auto-suffixed by the server (&lt;code&gt;email&lt;/code&gt; became &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;email_1&lt;/code&gt;, &lt;code&gt;email_2&lt;/code&gt;...). To the user filling out the form, it looked like the form had been designed by someone who really, really wanted your email address.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why &lt;code&gt;field_add&lt;/code&gt; Isn't Idempotent
&lt;/h2&gt;

&lt;p&gt;Here's the thing about &lt;code&gt;field_add&lt;/code&gt; — it's a create operation, not an upsert. If you call it twice with the same field ID, the server creates two fields. The field ID isn't a unique constraint; it's a label.&lt;/p&gt;

&lt;p&gt;Looking at the MCP tool definition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;field_add&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Add a new field&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;appId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;App ID&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Field ID&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;input&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Field type (default: input)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Field title / question 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;// ...&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`assess form add --app &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;appId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; --id &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; --type &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="kd"&gt;type&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="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's no check for "does this field ID already exist?" The tool just sends the add command and the server creates a new field. If a field with that ID already exists, the server either appends a suffix or creates a duplicate.&lt;/p&gt;

&lt;p&gt;From a CLI perspective, this is fine. A human running &lt;code&gt;formlm-cli field add&lt;/code&gt; knows whether they've already added a field. They don't need the CLI to check for them.&lt;/p&gt;

&lt;p&gt;From an AI perspective, this is a problem. The AI doesn't remember what it did in a previous session. It doesn't check whether fields exist before adding them. It just executes the plan, and if the plan says "add 12 fields," it adds 12 fields — regardless of whether 8 of them are already there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Find Before Add
&lt;/h2&gt;

&lt;p&gt;The pattern I now enforce is: &lt;strong&gt;always check before adding&lt;/strong&gt;. Before calling &lt;code&gt;field_add&lt;/code&gt;, call &lt;code&gt;field_find&lt;/code&gt; to see if a field with that ID already exists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Step 1: Check if the field already exists&lt;/span&gt;
field_find &lt;span class="nt"&gt;--app&lt;/span&gt; &amp;lt;appId&amp;gt; &lt;span class="nt"&gt;--id&lt;/span&gt; email

&lt;span class="c"&gt;# If the response is "field not found", proceed with add:&lt;/span&gt;
field_add &lt;span class="nt"&gt;--app&lt;/span&gt; &amp;lt;appId&amp;gt; &lt;span class="nt"&gt;--id&lt;/span&gt; email &lt;span class="nt"&gt;--type&lt;/span&gt; input &lt;span class="nt"&gt;--title&lt;/span&gt; &lt;span class="s2"&gt;"Email"&lt;/span&gt; &lt;span class="nt"&gt;--required&lt;/span&gt;

&lt;span class="c"&gt;# If the field already exists, skip the add (or update it instead)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In practice, I instruct Claude to batch this: call &lt;code&gt;field_list&lt;/code&gt; once to get all existing fields, then only add the ones that are missing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Get the current state&lt;/span&gt;
field_list &lt;span class="nt"&gt;--app&lt;/span&gt; &amp;lt;appId&amp;gt;
&lt;span class="c"&gt;# Response: [name, email, phone, rating]&lt;/span&gt;

&lt;span class="c"&gt;# Claude now knows which fields exist and only adds the missing ones&lt;/span&gt;
&lt;span class="c"&gt;# It needs to add: comments, feedback_source&lt;/span&gt;
field_add &lt;span class="nt"&gt;--app&lt;/span&gt; &amp;lt;appId&amp;gt; &lt;span class="nt"&gt;--id&lt;/span&gt; comments &lt;span class="nt"&gt;--type&lt;/span&gt; textarea &lt;span class="nt"&gt;--title&lt;/span&gt; &lt;span class="s2"&gt;"Additional comments"&lt;/span&gt;
field_add &lt;span class="nt"&gt;--app&lt;/span&gt; &amp;lt;appId&amp;gt; &lt;span class="nt"&gt;--id&lt;/span&gt; feedback_source &lt;span class="nt"&gt;--type&lt;/span&gt; radio &lt;span class="nt"&gt;--options&lt;/span&gt; &lt;span class="s2"&gt;"Search:1,Social:2,Friend:3"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the read-before-write pattern again (same as find-then-set from the previous article), but applied to creation instead of updates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not Just Make &lt;code&gt;field_add&lt;/code&gt; Idempotent?
&lt;/h2&gt;

&lt;p&gt;I considered making &lt;code&gt;field_add&lt;/code&gt; an upsert — if the field exists, update it; if not, create it. But that has its own problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Silent overwrites.&lt;/strong&gt; If the AI accidentally re-adds a field with different options, the upsert would silently overwrite the existing field's options. No error, no warning — just changed data.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;No explicit "create" semantics.&lt;/strong&gt; Sometimes you want to fail if a field already exists — like when you're setting up a new form and a duplicate field ID indicates a bug in your plan.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The update path is different.&lt;/strong&gt; Creating a field and updating a field go through different server-side validation. Merging them into one operation muddies the API.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So I kept &lt;code&gt;field_add&lt;/code&gt; as a pure create operation and moved the idempotency check to the caller. The AI is responsible for checking before adding.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Idempotency Wrapper
&lt;/h2&gt;

&lt;p&gt;In my system prompt for Claude, I now include this instruction:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Before adding any field, call field_list to get the current field set. Only add fields whose IDs don't already exist in the form. If a field already exists and needs changes, use field_update or field_set_property instead of field_add."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is a soft constraint — it relies on Claude reading and following the instruction. In practice, it works about 90% of the time. The other 10%, Claude skips the &lt;code&gt;field_list&lt;/code&gt; call and goes straight to &lt;code&gt;field_add&lt;/code&gt;, usually when it's "in a hurry" (i.e., the context window is getting full and it's trying to save tokens).&lt;/p&gt;

&lt;p&gt;The hard constraint would be making the server reject duplicate field IDs. But as I mentioned, field IDs aren't unique constraints — they're labels. Making them unique would break existing forms that intentionally use the same ID across different field types (don't ask).&lt;/p&gt;

&lt;h2&gt;
  
  
  The Deeper Lesson: AI Agents Need Idempotency Everywhere
&lt;/h2&gt;

&lt;p&gt;This isn't just about &lt;code&gt;field_add&lt;/code&gt;. Every create operation in an AI-accessible API needs an idempotency story:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;app_create&lt;/code&gt;&lt;/strong&gt; — what if the AI creates the same app twice? Currently it creates two apps with the same name. I should probably check for existing apps with the same name before creating.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;share_publish&lt;/code&gt;&lt;/strong&gt; — what if the AI publishes the same form twice? Currently it's idempotent (publishing an already-published form is a no-op). This one got it right.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;auth_login&lt;/code&gt;&lt;/strong&gt; — what if the AI logs in twice? Currently it overwrites the existing token. Also fine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern I now follow for any create operation:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Check-first:&lt;/strong&gt; Provide a read operation that lets the AI verify whether the resource already exists&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Document it:&lt;/strong&gt; In the schema description, explicitly say "Check with [X] before calling this to avoid duplicates"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Soft idempotency:&lt;/strong&gt; If the operation is called with the same parameters, make it a no-op rather than creating a duplicate, where possible&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Cleanup
&lt;/h2&gt;

&lt;p&gt;Cleaning up the duplicated form was painful. I had to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Call &lt;code&gt;field_list&lt;/code&gt; to see all 20 fields&lt;/li&gt;
&lt;li&gt;Identify the duplicates (fields with suffixed IDs like &lt;code&gt;email_1&lt;/code&gt;, &lt;code&gt;email_2&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Call &lt;code&gt;field_remove&lt;/code&gt; for each duplicate&lt;/li&gt;
&lt;li&gt;Re-order the remaining fields with &lt;code&gt;field_move&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Twelve &lt;code&gt;field_remove&lt;/code&gt; calls. Each one was a round-trip to the server. It took longer to clean up than it took to create the mess.&lt;/p&gt;

&lt;p&gt;After that experience, I wrote a cleanup script that detects and removes duplicate fields by comparing titles and types. It's not part of the CLI — it's a one-off shell script I keep in my snippets folder. If this happens often enough, I might add a &lt;code&gt;field_dedup&lt;/code&gt; command to the CLI. But for now, the find-before-add pattern is enough.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The formlm-cli MCP server documents the find-before-add pattern in tool descriptions. Open source at &lt;a href="https://github.com/formlm/cli" rel="noopener noreferrer"&gt;github.com/formlm/cli&lt;/a&gt; — try the platform at &lt;a href="https://formlm.me" rel="noopener noreferrer"&gt;formlm.me&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>productivity</category>
      <category>architecture</category>
    </item>
    <item>
      <title>The Boolean Trap: When Your AI Literally Cannot Say "False"</title>
      <dc:creator>Keo Fung | FormLM</dc:creator>
      <pubDate>Thu, 23 Jul 2026 09:31:48 +0000</pubDate>
      <link>https://dev.to/keofung/the-boolean-trap-when-your-ai-literally-cannot-say-false-425a</link>
      <guid>https://dev.to/keofung/the-boolean-trap-when-your-ai-literally-cannot-say-false-425a</guid>
      <description>&lt;p&gt;This one took me two hours to debug. Two hours of staring at a form where every single field was marked "required" — even the ones I'd explicitly told Claude to make optional. The AI wasn't ignoring me. It physically could not express what I was asking for.&lt;/p&gt;

&lt;p&gt;Here's how a TypeScript boolean type and a commander.js flag convention teamed up to make &lt;code&gt;false&lt;/code&gt; an impossible value.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update (v0.2.0)&lt;/strong&gt;: The &lt;code&gt;field_add&lt;/code&gt; tool is now accessed via &lt;code&gt;formlm_exec&lt;/code&gt; (direct command execution). The boolean trap and its fix (&lt;code&gt;normBool()&lt;/code&gt; + &lt;code&gt;!== undefined&lt;/code&gt; checks) remain fully applicable. The &lt;code&gt;formlm_generate&lt;/code&gt; smart pipeline bypasses this issue entirely by letting the server-side AssessAgent handle field creation.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;I was building a customer feedback form. Most fields were required (name, email, rating), but a few were optional — a text field for "additional comments" and a radio for "how did you hear about us." Standard stuff.&lt;/p&gt;

&lt;p&gt;I told Claude:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Create a feedback form. Name and email are required. The comments field is optional. The 'how did you hear about us' field is optional."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude went to work. It created the app, added the name and email fields with &lt;code&gt;required: true&lt;/code&gt;, and then... this happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Claude Did
&lt;/h2&gt;

&lt;p&gt;For the required fields, Claude called &lt;code&gt;field_add&lt;/code&gt; with &lt;code&gt;required: true&lt;/code&gt;. Clean, correct:&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;"appId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"app_abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Email address"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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;For the optional fields, Claude called &lt;code&gt;field_add&lt;/code&gt; and simply... didn't include the &lt;code&gt;required&lt;/code&gt; parameter:&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;"appId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"app_abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"comments"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"textarea"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Additional comments"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;no&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"required"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;field&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;all&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;On the surface, this looks fine. The field was created. The form rendered. But every single field — including the "optional" ones — showed up with a red asterisk. Every field was required.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Root Cause
&lt;/h2&gt;

&lt;p&gt;Here's the thing. In the MCP schema, &lt;code&gt;required&lt;/code&gt; is defined as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Mark as required&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;code&gt;z.boolean().optional()&lt;/code&gt; means the parameter accepts &lt;code&gt;true&lt;/code&gt;, &lt;code&gt;false&lt;/code&gt;, or &lt;code&gt;undefined&lt;/code&gt; (not provided). When Claude wants to make a field optional, it has two options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pass &lt;code&gt;required: false&lt;/code&gt; — explicitly set it to false&lt;/li&gt;
&lt;li&gt;Don't pass &lt;code&gt;required&lt;/code&gt; at all — leave it undefined&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Claude chose option 2. And here's where the problem starts.&lt;/p&gt;

&lt;p&gt;When &lt;code&gt;required&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;, the CLI builds the command string like this:&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;required&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normBool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;required&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;required&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="s2"&gt;` --required=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;required&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;required&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;, the &lt;code&gt;--required&lt;/code&gt; flag is never added to the command. The server receives the &lt;code&gt;assess form add&lt;/code&gt; command without any &lt;code&gt;--required&lt;/code&gt; parameter.&lt;/p&gt;

&lt;p&gt;And the server's default behavior? When no &lt;code&gt;--required&lt;/code&gt; flag is present, it defaults to &lt;code&gt;true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So "I didn't say required" became "required by default." Every optional field silently became mandatory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Claude Didn't Pass &lt;code&gt;false&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;I tested this. I explicitly told Claude: "Make the comments field NOT required. Set required to false."&lt;/p&gt;

&lt;p&gt;Claude tried. It passed &lt;code&gt;required: false&lt;/code&gt; in the MCP parameters. But here's what happened on the CLI side:&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;normBool&lt;/code&gt; function in field.ts handles three cases:&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;normBool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&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="nx"&gt;boolean&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;v&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;undefined&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;boolean&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;v&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;v&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the MCP server receives &lt;code&gt;required: false&lt;/code&gt; (a proper boolean), it passes it through and builds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;assess form add &lt;span class="nt"&gt;--app&lt;/span&gt; &amp;lt;appId&amp;gt; &lt;span class="nt"&gt;--id&lt;/span&gt; comments &lt;span class="nt"&gt;--required&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt; &lt;span class="nt"&gt;--json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This should work. And in my local testing, it did work. But in Claude Desktop, something weirder happened — Claude kept omitting the parameter instead of passing &lt;code&gt;false&lt;/code&gt;. When I checked the conversation log, Claude's reasoning was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The field is optional, so I don't need to set required."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude was treating "optional" as "don't set the flag" rather than "set the flag to false." From a human perspective, that makes sense — if something is optional, you don't mention it. But from a programmatic perspective, "not setting it" and "setting it to false" are different operations when the default is &lt;code&gt;true&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Problem: Defaults That Bite
&lt;/h2&gt;

&lt;p&gt;The deeper issue is the server-side default. When &lt;code&gt;field_add&lt;/code&gt; is called without &lt;code&gt;--required&lt;/code&gt;, the server treats it as &lt;code&gt;required=true&lt;/code&gt;. That's a reasonable default for form fields — most fields in most forms are required.&lt;/p&gt;

&lt;p&gt;But it creates a trap: the only way to make a field optional is to explicitly pass &lt;code&gt;required=false&lt;/code&gt;. And AI agents are bad at explicitly passing &lt;code&gt;false&lt;/code&gt; for boolean parameters because:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;They've been trained on tons of code where "optional" means "don't pass it"&lt;/li&gt;
&lt;li&gt;The schema says &lt;code&gt;.optional()&lt;/code&gt;, which reinforces the "just don't include it" pattern&lt;/li&gt;
&lt;li&gt;Passing &lt;code&gt;false&lt;/code&gt; feels redundant when the field isn't required — why state the obvious?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result is a silent data corruption: fields that should be optional become required, and nobody notices until a user complains that they can't submit the form without filling in the "optional" comments field.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;I tried three approaches before settling on one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 1: Change the server default.&lt;/strong&gt; Make &lt;code&gt;required&lt;/code&gt; default to &lt;code&gt;false&lt;/code&gt; when not specified. This fixed the AI problem but broke every existing form build — now Claude had to explicitly pass &lt;code&gt;required: true&lt;/code&gt; for every required field, and it kept forgetting. Same problem, opposite direction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 2: Remove the default entirely.&lt;/strong&gt; Force the parameter to be required. This made the schema non-optional, which meant Claude had to always pass &lt;code&gt;required: true&lt;/code&gt; or &lt;code&gt;required: false&lt;/code&gt;. It worked, but it made every &lt;code&gt;field_add&lt;/code&gt; call noisier. And for fields where required-ness doesn't matter (like a description field), it was confusing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 3: Fix the schema description.&lt;/strong&gt; This is what I went with. I changed the &lt;code&gt;.describe()&lt;/code&gt; string to be explicit about the default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Mark as required. Defaults to true if omitted. To make a field optional, explicitly set this to false.&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;That one sentence — "To make a field optional, explicitly set this to false" — was the instruction Claude needed. After this change, Claude started passing &lt;code&gt;required: false&lt;/code&gt; for optional fields instead of omitting the parameter.&lt;/p&gt;

&lt;p&gt;It's a band-aid, not a real fix. The real fix is making the server's default behavior match the common-sense expectation: if you don't specify &lt;code&gt;required&lt;/code&gt;, the field should be optional, not required. But changing a default that existing forms depend on is a migration I'm not ready to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;normBool&lt;/code&gt; Safety Net
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;normBool&lt;/code&gt; function itself is fine — it correctly handles &lt;code&gt;true&lt;/code&gt;, &lt;code&gt;false&lt;/code&gt;, the string &lt;code&gt;"false"&lt;/code&gt;, and &lt;code&gt;undefined&lt;/code&gt;. The problem was never in the function. It was in the gap between:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What the schema says (&lt;code&gt;.optional()&lt;/code&gt; → "you can skip this")&lt;/li&gt;
&lt;li&gt;What the server does (defaults to &lt;code&gt;true&lt;/code&gt; when skipped)&lt;/li&gt;
&lt;li&gt;What the AI assumes (skipping = not required)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three layers, each reasonable in isolation, combine to produce a bug where "optional" silently means "required."&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pattern I Now Follow
&lt;/h2&gt;

&lt;p&gt;For any boolean parameter where &lt;code&gt;false&lt;/code&gt; is a meaningful value, I:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Make the description explicit about the default.&lt;/strong&gt; If the default is &lt;code&gt;true&lt;/code&gt;, say so. If omitting it means &lt;code&gt;true&lt;/code&gt;, say that explicitly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the &lt;code&gt;false&lt;/code&gt; path.&lt;/strong&gt; Every boolean parameter gets tested with &lt;code&gt;true&lt;/code&gt;, &lt;code&gt;false&lt;/code&gt;, and omitted. The omitted case is the one that bites you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't rely on "omit means false."&lt;/strong&gt; In a world where an AI is reading your schema and making decisions, the safest approach is to make every state explicit. If &lt;code&gt;false&lt;/code&gt; is a valid and important value, make the parameter required, not optional.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The boolean trap isn't unique to my CLI. It's a general problem with AI-accessible APIs: optional booleans with non-null defaults are a footgun. The AI's instinct is to omit rather than to explicitly negate, and if your default doesn't match "omitted = the safe option," you'll get silent bugs.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The formlm-cli MCP server has explicit descriptions for all boolean parameters. Open source at &lt;a href="https://github.com/formlm/cli" rel="noopener noreferrer"&gt;github.com/formlm/cli&lt;/a&gt; — try the platform at &lt;a href="https://formlm.me" rel="noopener noreferrer"&gt;formlm.me&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>automation</category>
      <category>java</category>
    </item>
    <item>
      <title>MCP Tools, One Weekend: Stress-Testing an AI-Accessible Form Builder</title>
      <dc:creator>Keo Fung | FormLM</dc:creator>
      <pubDate>Wed, 22 Jul 2026 09:02:27 +0000</pubDate>
      <link>https://dev.to/keofung/mcp-tools-one-weekend-stress-testing-an-ai-accessible-form-builder-4boo</link>
      <guid>https://dev.to/keofung/mcp-tools-one-weekend-stress-testing-an-ai-accessible-form-builder-4boo</guid>
      <description>&lt;p&gt;I have 20 MCP tools in formlm-cli. On paper they cover the full lifecycle: auth, app management, field CRUD, and sharing. But "covers the lifecycle" and "actually works when you throw a complex real-world task at an AI agent" are different things.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update (v0.2.0)&lt;/strong&gt;: The architecture has been redesigned from 20 flat tools to 6 layered tools + 6 knowledge resources. The &lt;code&gt;formlm_generate&lt;/code&gt; tool now handles app creation + field generation + scoring + publishing in a single call via the server-side AssessAgent pipeline. The &lt;code&gt;formlm_exec&lt;/code&gt; tool provides direct access to all whitelisted CLI commands. The individual tools described in this article (&lt;code&gt;app_create&lt;/code&gt;, &lt;code&gt;field_add&lt;/code&gt;, &lt;code&gt;share_publish&lt;/code&gt;, etc.) are now accessed through &lt;code&gt;formlm_exec&lt;/code&gt;. The "current app" state issue mentioned below has been fixed via server-side SessionContext.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So I spent a weekend trying to build a genuinely complex form — an enterprise onboarding assessment with multiple field types, conditional logic, and scoring — using only Claude Desktop and the MCP tools. Here's what worked, what didn't, and what surprised me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Task
&lt;/h2&gt;

&lt;p&gt;Build an "Enterprise Onboarding Readiness Assessment" with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;5 text/input fields (employee name, department, role, manager, start date)&lt;/li&gt;
&lt;li&gt;2 radio fields with scoring (technical proficiency, communication level)&lt;/li&gt;
&lt;li&gt;3 scale fields (1-10) for confidence dimensions&lt;/li&gt;
&lt;li&gt;1 textarea for open feedback&lt;/li&gt;
&lt;li&gt;Field ordering (specific sequence)&lt;/li&gt;
&lt;li&gt;Published with a shareable URL&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not the most complex form in the world, but complex enough to exercise most of the toolset. I wanted to hit at least 15 of the 20 tools in a single session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Saturday: Building the Form
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Tools used: app_create, field_schema, field_config, field_add (×11)
&lt;/h3&gt;

&lt;p&gt;I started with a prompt describing the full form. Claude's first move was &lt;code&gt;app_create&lt;/code&gt; — clean, no issues.&lt;/p&gt;

&lt;p&gt;Then it did something I've come to expect: it called &lt;code&gt;field_schema&lt;/code&gt; before adding any fields. It wanted to see the available types. The response listed &lt;code&gt;input&lt;/code&gt;, &lt;code&gt;textarea&lt;/code&gt;, &lt;code&gt;radio&lt;/code&gt;, &lt;code&gt;scale&lt;/code&gt;, &lt;code&gt;date&lt;/code&gt;, and others. Claude picked &lt;code&gt;date&lt;/code&gt; for the start date field instead of &lt;code&gt;input&lt;/code&gt;, which was smart — it means the form will show a date picker instead of a text box.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;field_config --type radio&lt;/code&gt; call came next. Claude wanted to know what properties a radio field accepts. The response showed &lt;code&gt;options&lt;/code&gt; (comma-separated with &lt;code&gt;label:score&lt;/code&gt; format) and &lt;code&gt;required&lt;/code&gt;. Claude then added the radio fields with properly scored options:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;field_add &lt;span class="nt"&gt;--app&lt;/span&gt; &amp;lt;appId&amp;gt; &lt;span class="nt"&gt;--id&lt;/span&gt; tech &lt;span class="nt"&gt;--type&lt;/span&gt; radio &lt;span class="nt"&gt;--options&lt;/span&gt; &lt;span class="s2"&gt;"Beginner:1,Intermediate:2,Advanced:3,Expert:4"&lt;/span&gt; &lt;span class="nt"&gt;--required&lt;/span&gt;
field_add &lt;span class="nt"&gt;--app&lt;/span&gt; &amp;lt;appId&amp;gt; &lt;span class="nt"&gt;--id&lt;/span&gt; &lt;span class="nb"&gt;comm&lt;/span&gt; &lt;span class="nt"&gt;--type&lt;/span&gt; radio &lt;span class="nt"&gt;--options&lt;/span&gt; &lt;span class="s2"&gt;"Needs improvement:1,Meets expectations:2,Exceeds expectations:3"&lt;/span&gt; &lt;span class="nt"&gt;--required&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scale fields came next:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;field_add &lt;span class="nt"&gt;--app&lt;/span&gt; &amp;lt;appId&amp;gt; &lt;span class="nt"&gt;--id&lt;/span&gt; conf_tools &lt;span class="nt"&gt;--type&lt;/span&gt; scale &lt;span class="nt"&gt;--min&lt;/span&gt; 1 &lt;span class="nt"&gt;--max&lt;/span&gt; 10 &lt;span class="nt"&gt;--title&lt;/span&gt; &lt;span class="s2"&gt;"How confident are you with the tools?"&lt;/span&gt;
field_add &lt;span class="nt"&gt;--app&lt;/span&gt; &amp;lt;appId&amp;gt; &lt;span class="nt"&gt;--id&lt;/span&gt; conf_team &lt;span class="nt"&gt;--type&lt;/span&gt; scale &lt;span class="nt"&gt;--min&lt;/span&gt; 1 &lt;span class="nt"&gt;--max&lt;/span&gt; 10 &lt;span class="nt"&gt;--title&lt;/span&gt; &lt;span class="s2"&gt;"How confident are you with your team?"&lt;/span&gt;
field_add &lt;span class="nt"&gt;--app&lt;/span&gt; &amp;lt;appId&amp;gt; &lt;span class="nt"&gt;--id&lt;/span&gt; conf_process &lt;span class="nt"&gt;--type&lt;/span&gt; scale &lt;span class="nt"&gt;--min&lt;/span&gt; 1 &lt;span class="nt"&gt;--max&lt;/span&gt; 10 &lt;span class="nt"&gt;--title&lt;/span&gt; &lt;span class="s2"&gt;"How confident are you with the processes?"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eleven &lt;code&gt;field_add&lt;/code&gt; calls. All succeeded. No issues.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tools used: field_list, field_move
&lt;/h3&gt;

&lt;p&gt;Here's where I hit my first snag. The fields were added in the right order, but I wanted the textarea (open feedback) to appear last, after the scale fields. Claude had added it in the middle.&lt;/p&gt;

&lt;p&gt;Claude called &lt;code&gt;field_list&lt;/code&gt; to see the current ordering:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;field_list &lt;span class="nt"&gt;--app&lt;/span&gt; &amp;lt;appId&amp;gt;
&lt;span class="c"&gt;# Response: [name, department, role, manager, start_date, tech, comm, conf_tools, conf_team, conf_process, feedback]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then &lt;code&gt;field_move&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;field_move &lt;span class="nt"&gt;--app&lt;/span&gt; &amp;lt;appId&amp;gt; &lt;span class="nt"&gt;--id&lt;/span&gt; feedback &lt;span class="nt"&gt;--pos&lt;/span&gt; 11
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worked. The feedback field moved to the end.&lt;/p&gt;

&lt;p&gt;This was a good test of &lt;code&gt;field_move&lt;/code&gt; — it's a tool that seems simple but has edge cases. What if the position is out of range? What if the field doesn't exist? I tested both scenarios manually afterward. Out-of-range position silently clamps to the end. Non-existent field ID returns an error. Reasonable behavior.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tools used: field_find
&lt;/h3&gt;

&lt;p&gt;I asked Claude to check the scoring on the &lt;code&gt;tech&lt;/code&gt; field. It called &lt;code&gt;field_find&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;field_find&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;--app&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;lt;appId&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;--id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;tech&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Response:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;id:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tech"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;type:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"radio"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;options:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="err"&gt;label:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Beginner"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;score:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The find response was clean and structured. Claude could read the scores and confirm they were correct.&lt;/p&gt;

&lt;p&gt;One thing I noticed: the &lt;code&gt;field_find&lt;/code&gt; response includes a lot of metadata — field type, validation rules, placeholder text, etc. For a human reading the JSON, it's fine. For an AI parsing it, the extra fields add tokens but don't cause confusion. Claude had no trouble extracting what it needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sunday: Publishing and Cleanup
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Tools used: share_publish, share_url, share_query
&lt;/h3&gt;

&lt;p&gt;Publishing was the easiest part. One &lt;code&gt;share_publish&lt;/code&gt; call, one &lt;code&gt;share_url&lt;/code&gt; call. Done.&lt;/p&gt;

&lt;p&gt;I tested &lt;code&gt;share_query&lt;/code&gt; to check the publish status:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;share_query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;--app&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;lt;appId&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Response:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;published:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;formType:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"all"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;formPerm:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;formDay:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3650000&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 &lt;code&gt;formDay: 3650000&lt;/code&gt; is the "never expires" default I hardcoded in the MCP tool. That's intentional — the AI doesn't need to decide expiry dates for public forms. Safe defaults matter.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tools used: share_unpublish, app_remove
&lt;/h3&gt;

&lt;p&gt;After testing, I unpublished and deleted the app to clean up. Both worked without issue. The &lt;code&gt;app_remove&lt;/code&gt; is permanent — no confirmation prompt, no undo. That's slightly terrifying for an AI-accessible tool, but the whitelist on the backend would block a &lt;code&gt;clear&lt;/code&gt; command (which deletes ALL apps). Individual deletes are safe as long as the AI targets the right app ID.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Didn't Use
&lt;/h2&gt;

&lt;p&gt;Out of 20 tools, I used 11 in this session:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;auth_login&lt;/code&gt;, &lt;code&gt;auth_status&lt;/code&gt; — skipped, I was already logged in&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;app_list&lt;/code&gt;, &lt;code&gt;app_get&lt;/code&gt;, &lt;code&gt;app_update&lt;/code&gt; — didn't need to list or update apps&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;field_update&lt;/code&gt;, &lt;code&gt;field_remove&lt;/code&gt;, &lt;code&gt;field_set_property&lt;/code&gt; — didn't need to modify or remove fields&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's fine. Not every form build needs all 20 tools. The test was whether the tools I did need worked correctly when chained together by an AI agent. They did.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Broke
&lt;/h2&gt;

&lt;p&gt;Two things went wrong over the weekend:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Claude forgot the app ID mid-session.&lt;/strong&gt; After adding about eight fields, Claude lost track of the app ID it had created at the start. It tried to use &lt;code&gt;field_add&lt;/code&gt; without the &lt;code&gt;--app&lt;/code&gt; parameter. The error message was clear ("Missing required option: --app"), and Claude recovered by calling &lt;code&gt;app_list&lt;/code&gt; to find the app. But it cost three extra API calls and broke the flow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix idea:&lt;/strong&gt; The MCP server could maintain a "current app" state, similar to how &lt;code&gt;git&lt;/code&gt; remembers your current branch. &lt;code&gt;app_get&lt;/code&gt; could set the active app, and subsequent field commands could default to it. &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update (v0.2.0)&lt;/strong&gt;: This has been implemented. The server-side SessionContext now maintains the current app ID. When an app is created or used, the session automatically tracks it, and subsequent commands default to the active app.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;2. The &lt;code&gt;--filter&lt;/code&gt; parameter on &lt;code&gt;field_find&lt;/code&gt; returned too many results.&lt;/strong&gt; When Claude searched for "confidence" (trying to find the scale fields), the filter matched on field titles and returned all three confidence fields plus the textarea (which mentioned "confident" in its placeholder text). Not wrong, but noisier than expected. Claude handled it by reading the response and picking the right fields, but the filter could be more precise — maybe matching on field ID or key instead of title text.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Scorecard
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool Category&lt;/th&gt;
&lt;th&gt;Tools&lt;/th&gt;
&lt;th&gt;Used This Weekend&lt;/th&gt;
&lt;th&gt;Worked?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Auth&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;N/A (already logged in)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;2 (create, delete)&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Field&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;9 (all of them, across the session)&lt;/td&gt;
&lt;td&gt;✅ with minor issues&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Share&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;4 (all of them)&lt;/td&gt;
&lt;td&gt;✅&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;20&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;15&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;15/15 passed&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every tool I used worked. The issues were with Claude's context management (forgetting the app ID) and search precision (filter returning too many results), not with the tools themselves.&lt;/p&gt;

&lt;p&gt;The takeaway: 20 tools was enough coverage for a complex form build. In v0.2.0, the architecture was streamlined to 6 layered tools — &lt;code&gt;formlm_generate&lt;/code&gt; handles the entire build in one call, while &lt;code&gt;formlm_exec&lt;/code&gt; provides direct access for fine-grained control. The gaps aren't in the toolset — they're in the workflow ergonomics (state management, search precision). Those are fixable without adding new tools.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;All MCP tools are available in formlm-cli. Open source at &lt;a href="https://github.com/formlm/cli" rel="noopener noreferrer"&gt;github.com/formlm/cli&lt;/a&gt; — try the platform at &lt;a href="https://formlm.me" rel="noopener noreferrer"&gt;formlm.me&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>automation</category>
      <category>java</category>
    </item>
    <item>
      <title>The find-then-set Pattern: How I Stopped My AI Agent From Breaking Fields</title>
      <dc:creator>Keo Fung | FormLM</dc:creator>
      <pubDate>Tue, 21 Jul 2026 10:38:08 +0000</pubDate>
      <link>https://dev.to/keofung/the-find-then-set-pattern-how-i-stopped-my-ai-agent-from-breaking-fields-429</link>
      <guid>https://dev.to/keofung/the-find-then-set-pattern-how-i-stopped-my-ai-agent-from-breaking-fields-429</guid>
      <description>&lt;p&gt;It started with a radio field that lost its scores. Claude was trying to update one option's label on a question, and somehow every score on that field vanished. The form still worked — it just stopped scoring anything. Nobody noticed until a respondent completed the assessment and got a zero.&lt;/p&gt;

&lt;p&gt;Here's how it happened, and the pattern I adopted to make sure it never happens again.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update (v0.2.0)&lt;/strong&gt;: The individual field tools (&lt;code&gt;field_find&lt;/code&gt;, &lt;code&gt;field_set_property&lt;/code&gt;, &lt;code&gt;field_update&lt;/code&gt;) are now accessed via &lt;code&gt;formlm_exec&lt;/code&gt; — the direct command execution tool. The find-then-set pattern remains the recommended approach for fine-grained field updates. For bulk changes, &lt;code&gt;formlm_modify&lt;/code&gt; wraps the server-side BuilderAgent pipeline which handles field updates safely.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Incident
&lt;/h2&gt;

&lt;p&gt;I had a radio field with three options:&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;field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;q3&lt;/span&gt;
&lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;radio&lt;/span&gt;
&lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Satisfied:3"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Neutral:2"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unsatisfied:1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I asked Claude: "Change the 'Neutral' label to 'Okay' on field q3."&lt;/p&gt;

&lt;p&gt;Claude called &lt;code&gt;field_update&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;field_update &lt;span class="nt"&gt;--app&lt;/span&gt; &amp;lt;appId&amp;gt; &lt;span class="nt"&gt;--id&lt;/span&gt; q3 &lt;span class="nt"&gt;--options&lt;/span&gt; &lt;span class="s2"&gt;"Satisfied:3,Okay:2,Unsatisfied:1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks fine, right? It passed all three options, keeping the scores intact. The label changed. The scores stayed. Problem solved.&lt;/p&gt;

&lt;p&gt;Except that's not what happened every time. In a different session, Claude decided to be "efficient" and only pass the option it wanted to change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;field_update &lt;span class="nt"&gt;--app&lt;/span&gt; &amp;lt;appId&amp;gt; &lt;span class="nt"&gt;--id&lt;/span&gt; q3 &lt;span class="nt"&gt;--options&lt;/span&gt; &lt;span class="s2"&gt;"Okay:2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--options&lt;/code&gt; parameter in &lt;code&gt;field_update&lt;/code&gt; &lt;strong&gt;replaces&lt;/strong&gt; the entire options list. It doesn't merge. So that command wiped out "Satisfied:3" and "Unsatisfied:1" and left the field with a single option: "Okay:2."&lt;/p&gt;

&lt;p&gt;The form now had one radio button instead of three. The other two options were gone. And since the remaining option had a score of 2, every respondent who clicked it got 2 points — for a question that used to have a range of 1 to 3.&lt;/p&gt;

&lt;p&gt;The respondent who triggered the discovery answered "Okay" on this question and nothing else — because there was nothing else to answer. They got a 2 where they should've gotten context-dependent scoring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Happened
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;field_update&lt;/code&gt; command is documented as "Update a field." From an AI's perspective, that's a reasonable tool to use when you want to change something about a field. The schema says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Options list — replaces existing options&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;The description says "replaces existing options." That's accurate. But AI agents don't always read carefully when they're in the middle of a task. They see "update field" and "options" and they pass the option they want to change. The "replaces" part gets lost in the context window.&lt;/p&gt;

&lt;p&gt;This isn't Claude's fault. It's a UX problem with the tool design. &lt;code&gt;field_update&lt;/code&gt; is a blunt instrument — it replaces whatever you pass. If you only pass &lt;code&gt;--options&lt;/code&gt;, you get a new options list. If you only pass &lt;code&gt;--title&lt;/code&gt;, you get a new title and everything else stays. But the options parameter is particularly dangerous because it's a list — partial replacement of a list silently destroys data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: find-then-set
&lt;/h2&gt;

&lt;p&gt;I added &lt;code&gt;field_set_property&lt;/code&gt; specifically for this scenario. Instead of replacing the whole field, it updates a single property at a precise path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;field_set_property&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Fine-grained field property update&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;appId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;App ID&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Field ID&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Field Key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;property&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Property path (e.g. options.1.score)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Property value&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;async &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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`assess form set-property --app &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;appId&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="s2"&gt;` --id &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="s2"&gt;` --key &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&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;cmd&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="s2"&gt;` --property &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;property&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; --value "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&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;execCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, to change the "Neutral" label without touching scores, the AI uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;field_set_property &lt;span class="nt"&gt;--app&lt;/span&gt; &amp;lt;appId&amp;gt; &lt;span class="nt"&gt;--id&lt;/span&gt; q3 &lt;span class="nt"&gt;--property&lt;/span&gt; options.1.label &lt;span class="nt"&gt;--value&lt;/span&gt; &lt;span class="s2"&gt;"Okay"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This updates exactly one property — the label of option index 1. Nothing else changes. The scores, the other options, the field type — all untouched.&lt;/p&gt;

&lt;p&gt;But there's a catch. The AI needs to know that option index 1 is "Neutral" before it can update it. That's where &lt;code&gt;field_find&lt;/code&gt; comes in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Full Pattern
&lt;/h2&gt;

&lt;p&gt;The pattern I now enforce in my prompts is: &lt;strong&gt;find first, then set property&lt;/strong&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Call &lt;code&gt;field_find&lt;/code&gt; to get the current field state&lt;/li&gt;
&lt;li&gt;Identify the exact property path that needs changing&lt;/li&gt;
&lt;li&gt;Call &lt;code&gt;field_set_property&lt;/code&gt; with that precise path
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Step 1: Find the field&lt;/span&gt;
field_find &lt;span class="nt"&gt;--app&lt;/span&gt; &amp;lt;appId&amp;gt; &lt;span class="nt"&gt;--id&lt;/span&gt; q3

&lt;span class="c"&gt;# Response includes:&lt;/span&gt;
&lt;span class="c"&gt;# options: [&lt;/span&gt;
&lt;span class="c"&gt;#   { label: "Satisfied", score: 3 },&lt;/span&gt;
&lt;span class="c"&gt;#   { label: "Neutral", score: 2 },&lt;/span&gt;
&lt;span class="c"&gt;#   { label: "Unsatisfied", score: 1 }&lt;/span&gt;
&lt;span class="c"&gt;# ]&lt;/span&gt;

&lt;span class="c"&gt;# Step 2: I want to change options[1].label&lt;/span&gt;

&lt;span class="c"&gt;# Step 3: Set the property&lt;/span&gt;
field_set_property &lt;span class="nt"&gt;--app&lt;/span&gt; &amp;lt;appId&amp;gt; &lt;span class="nt"&gt;--id&lt;/span&gt; q3 &lt;span class="nt"&gt;--property&lt;/span&gt; options.1.label &lt;span class="nt"&gt;--value&lt;/span&gt; &lt;span class="s2"&gt;"Okay"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is more API calls (find + set vs. a single update). But it's safe. The AI can't accidentally destroy data because it's only touching one property at a time, and it saw the current state before making the change.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Use update vs. set-property
&lt;/h2&gt;

&lt;p&gt;I still use &lt;code&gt;field_update&lt;/code&gt; for legitimate full replacements — when I actually want to replace the entire options list, or change the field type, or update multiple properties at once. The rule I follow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;field_update&lt;/code&gt;&lt;/strong&gt; when you're replacing the entire structure of a field (new type, new options list, new title — a deliberate full replacement)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;field_set_property&lt;/code&gt;&lt;/strong&gt; when you're tweaking one thing (one option's label, one option's score, one property)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The AI needs to be told this explicitly. In my system prompt, I now include: "When changing a single property of a field, always use &lt;code&gt;field_set_property&lt;/code&gt; with a property path. Use &lt;code&gt;field_update&lt;/code&gt; only for full field replacement."&lt;/p&gt;

&lt;p&gt;That instruction, combined with the find-first pattern, has eliminated the "disappearing options" bug entirely. The cost is one extra API call per update. The benefit is not silently destroying form data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bigger Lesson
&lt;/h2&gt;

&lt;p&gt;When you design tools for AI agents, you have to think about what happens when the AI takes you literally but incompletely. "Update the label" doesn't mean "replace the entire options list with just the new label." But if the only tool available is a blunt "update" command, that's what can happen.&lt;/p&gt;

&lt;p&gt;Fine-grained tools (&lt;code&gt;set-property&lt;/code&gt;) combined with a read-before-write pattern (&lt;code&gt;find&lt;/code&gt; before &lt;code&gt;set&lt;/code&gt;) make AI interactions safer. Not because the AI is malicious — but because partial updates on complex objects are inherently dangerous when the tool doesn't distinguish between "replace this one thing" and "replace everything."&lt;/p&gt;

&lt;p&gt;The find-then-set pattern isn't new. It's how most database transactions work. But in the MCP world, where tools are the API and the AI is the client, it's easy to forget that your tools need the same safety patterns your database does.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The formlm-cli MCP server includes &lt;code&gt;field_set_property&lt;/code&gt; (accessible via &lt;code&gt;formlm_exec&lt;/code&gt;) specifically for this use case. Open source at &lt;a href="https://github.com/formlm/cli" rel="noopener noreferrer"&gt;github.com/formlm/cli&lt;/a&gt; — try it at &lt;a href="https://formlm.me" rel="noopener noreferrer"&gt;formlm.me&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>productivity</category>
      <category>agents</category>
    </item>
    <item>
      <title>I Let Claude Desktop Build My Entire Assessment Form. Here's What Happened.</title>
      <dc:creator>Keo Fung | FormLM</dc:creator>
      <pubDate>Tue, 21 Jul 2026 01:37:46 +0000</pubDate>
      <link>https://dev.to/keofung/i-let-claude-desktop-build-my-entire-assessment-form-heres-what-happened-4l5k</link>
      <guid>https://dev.to/keofung/i-let-claude-desktop-build-my-entire-assessment-form-heres-what-happened-4l5k</guid>
      <description>&lt;p&gt;&lt;strong&gt;Series: Building an AI-Native Assessment Platform (11/20)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I've been building FormLM's CLI and MCP server for a while now. The architecture is solid, the tools are defined, the zod schemas are clean. But there's a difference between "the code works" and "watching an AI agent actually use your tools to build something real."&lt;/p&gt;

&lt;p&gt;Last weekend I decided to stop testing in isolation and let Claude Desktop build a complete assessment form from scratch — using only the MCP tools I'd built. No hand-holding, no manual CLI commands, no editing the form in the web UI afterward. Just Claude, the MCP server, and a natural language prompt.&lt;/p&gt;

&lt;p&gt;Here's the full timeline, including the parts where it broke.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;I'd already installed formlm-cli globally and configured it in Claude Desktop's config:&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;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"formlm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"formlm-cli"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"mcp"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I'd logged in via &lt;code&gt;formlm-cli auth login&lt;/code&gt; beforehand so the token was already stored. Claude didn't need to authenticate — it could just start using the tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Prompt
&lt;/h2&gt;

&lt;p&gt;I gave Claude this instruction:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Build a burnout self-assessment form. Three dimensions: exhaustion, cynicism, and professional efficacy. Use the Maslach framework. Each dimension needs 3 questions on a 1-7 Likert scale. Add one reverse-scored item for the efficacy dimension. Then publish it and give me the URL."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's it. No field IDs, no type names, no scoring configuration details. I wanted to see if Claude could figure out the right tool calls from the schema descriptions alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Claude Did (Step by Step)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Create the app
&lt;/h3&gt;

&lt;p&gt;Claude called &lt;code&gt;app_create&lt;/code&gt; with the name "Burnout Self-Assessment" and a brief description. Got back an app ID. Straightforward.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Check available field types
&lt;/h3&gt;

&lt;p&gt;This surprised me. Before adding any fields, Claude called &lt;code&gt;field_schema&lt;/code&gt; to see what field types were available. It didn't assume it knew the types — it checked. The response listed all supported types including &lt;code&gt;scale&lt;/code&gt;, &lt;code&gt;radio&lt;/code&gt;, &lt;code&gt;input&lt;/code&gt;, &lt;code&gt;textarea&lt;/code&gt;, etc.&lt;/p&gt;

&lt;p&gt;Then it called &lt;code&gt;field_config --type scale&lt;/code&gt; to see what properties a scale field accepts. It found &lt;code&gt;min&lt;/code&gt;, &lt;code&gt;max&lt;/code&gt;, and the scoring options.&lt;/p&gt;

&lt;p&gt;This was the moment I realized the schema descriptions mattered more than I thought. Claude was reading them like documentation. The &lt;code&gt;.describe()&lt;/code&gt; strings on every zod field weren't just metadata — they were the instructions the AI followed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Add the exhaustion dimension (3 fields)
&lt;/h3&gt;

&lt;p&gt;Claude added three scale fields for the exhaustion dimension:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;field_add: id=ex1, type=scale, title="I feel emotionally drained by my work", min=1, max=7
field_add: id=ex2, type=scale, title="I feel used up at the end of the workday", min=1, max=7
field_add: id=ex3, type=scale, title="I feel fatigued when I get up in the morning and have to face another day", min=1, max=7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So far so good. The field IDs were sensible (&lt;code&gt;ex1&lt;/code&gt;, &lt;code&gt;ex2&lt;/code&gt;, &lt;code&gt;ex3&lt;/code&gt;). The titles were actual Maslach Burnout Inventory items, not made-up questions. Claude knew the framework.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Add cynicism and efficacy
&lt;/h3&gt;

&lt;p&gt;Same pattern for cynicism (&lt;code&gt;cy1&lt;/code&gt;, &lt;code&gt;cy2&lt;/code&gt;, &lt;code&gt;cy3&lt;/code&gt;) and professional efficacy (&lt;code&gt;pe1&lt;/code&gt;, &lt;code&gt;pe2&lt;/code&gt;, &lt;code&gt;pe3&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Here's where it got interesting. For the efficacy dimension, I'd asked for a reverse-scored item. Claude added:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;field_add: id=pe3, type=scale, title="I do NOT feel I am making an effective contribution to my organization", min=1, max=7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The "NOT" was Claude's way of making a reverse-scored item — by phrasing the question negatively so that a high score indicates low efficacy. Not how I'd implement reverse scoring (I'd use the &lt;code&gt;--negFields&lt;/code&gt; mechanism in the scale configuration), but for a form-only build without scale config, it was a reasonable approach.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Publish
&lt;/h3&gt;

&lt;p&gt;Claude called &lt;code&gt;share_publish&lt;/code&gt; with the app ID, then &lt;code&gt;share_url&lt;/code&gt; to get the fill-in URL. The whole thing came back as a link.&lt;/p&gt;

&lt;p&gt;Total time: about 90 seconds. Nine fields across three dimensions, published, with a live URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where It Got Weird
&lt;/h2&gt;

&lt;p&gt;The form worked. People could fill it out. But there were a few things that made me realize the tools weren't quite enough on their own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No dimensional grouping.&lt;/strong&gt; Claude added nine fields, but they were just nine individual scale questions. There was no "dimension" concept in the form — that lives in the Scale module, which the MCP server doesn't expose yet. So the form collected answers but couldn't compute dimension scores. That's a gap I need to fill.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No scoring logic.&lt;/strong&gt; Even if dimensions were configured, the MCP tools don't include scale configuration commands (by design — those are in the whitelist's blocked list because they're administrative). So the form was a data collection tool, not an assessment engine. To get scoring, I'd need to configure the Scale module separately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The reverse-scored item was textual, not structural.&lt;/strong&gt; Claude handled reverse scoring by writing a negatively-phrased question. That works for data collection but doesn't tell the scoring engine to invert the score. The real fix is the &lt;code&gt;--negFields&lt;/code&gt; parameter in scale configuration — but that's not exposed via MCP.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;The MCP tools are good at form construction. Claude figured out the right field types, the right scale ranges, the right question wording — all from the schema descriptions. The &lt;code&gt;.describe()&lt;/code&gt; strings did their job.&lt;/p&gt;

&lt;p&gt;But form construction is only the first 20% of an assessment. The other 80% — dimensional scoring, tier assignment, report generation — lives in modules the MCP server doesn't expose. That's intentional (security), but it means an AI agent can build the input form but not the assessment logic.&lt;/p&gt;

&lt;p&gt;My next step: figure out how to safely expose scale configuration to the MCP server without opening up the administrative commands that shouldn't be AI-accessible. The whitelist exists for a reason, but there's a middle ground between "AI can add fields" and "AI can reconfigure scoring rules."&lt;/p&gt;

&lt;p&gt;For now, the workflow is: Claude builds the form, I configure the scoring by hand. That's still way faster than building the whole thing manually. But it's not the fully AI-driven assessment pipeline I want it to be.&lt;/p&gt;

&lt;p&gt;Still — watching Claude build a complete, publishable form in 90 seconds from a one-sentence prompt was a moment. The tools work. The schemas work. There's just more pipeline to expose.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is part of a series on building AI-native tooling for FormLM. The CLI and MCP server are open source — check them out at &lt;a href="https://github.com/formlm/cli" rel="noopener noreferrer"&gt;github.com/formlm/cli&lt;/a&gt; or try the platform at &lt;a href="https://formlm.me" rel="noopener noreferrer"&gt;formlm.me&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>productivity</category>
      <category>rpa</category>
    </item>
    <item>
      <title>[10/10]PDF Rendering War: SVG-to-PNG Conversion, Emoji Font Routing, and the iText NPE</title>
      <dc:creator>Keo Fung | FormLM</dc:creator>
      <pubDate>Tue, 21 Jul 2026 01:36:46 +0000</pubDate>
      <link>https://dev.to/keofung/1010pdf-rendering-war-svg-to-png-conversion-emoji-font-routing-and-the-itext-npe-9d4</link>
      <guid>https://dev.to/keofung/1010pdf-rendering-war-svg-to-png-conversion-emoji-font-routing-and-the-itext-npe-9d4</guid>
      <description>&lt;p&gt;&lt;strong&gt;Series: Building a Modular Assessment Engine (10/10)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The final boss of this project: PDF rendering. The report module generates HTML, which iText converts to PDF. In theory, HTML → PDF is straightforward. In practice, it's a warzone of SVG crashes, emoji invisibility, and font routing nightmares. This post covers the three rendering battles and how each was won.&lt;/p&gt;

&lt;h2&gt;
  
  
  Battle 1: The SVG NPE That Crashed Every Report
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Bug
&lt;/h3&gt;

&lt;p&gt;Reports with charts (radar charts, bar charts) would crash during PDF generation with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;itextpdf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;kernel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PdfException&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Cannot&lt;/span&gt; &lt;span class="n"&gt;convert&lt;/span&gt; &lt;span class="nc"&gt;PdfArray&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;array&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;itextpdf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;kernel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pdf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PdfArray&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toFloatArray&lt;/span&gt;&lt;span class="o"&gt;(...)&lt;/span&gt;
    &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;itextpdf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;svg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;converter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SvgConverter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;drawToPdf&lt;/span&gt;&lt;span class="o"&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The NPE occurred in iText's SVG renderer (&lt;code&gt;Batik&lt;/code&gt;-based) when it tried to parse SVG path data. The crash was total — no PDF was produced. The user saw "Report generation failed."&lt;/p&gt;

&lt;h3&gt;
  
  
  The Root Cause
&lt;/h3&gt;

&lt;p&gt;The chart widgets generated SVG as &lt;code&gt;data:image/svg+xml;base64,...&lt;/code&gt; URLs embedded in &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tags. This works fine in browsers. But iText's SVG renderer has known issues with:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Complex &lt;code&gt;&amp;lt;path&amp;gt;&lt;/code&gt; &lt;code&gt;d&lt;/code&gt; attributes&lt;/strong&gt; — Batik's &lt;code&gt;PdfArray.toFloatArray()&lt;/code&gt; fails on certain path command sequences&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CSS-in-SVG attributes&lt;/strong&gt; — iText doesn't fully support CSS properties on SVG elements&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;&amp;lt;text&amp;gt;&lt;/code&gt; with &lt;code&gt;text-anchor&lt;/code&gt;&lt;/strong&gt; — partially supported, causes PdfNumber parsing errors&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Four different widget types were affected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ChartWidget&lt;/code&gt; (general charts)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;FieldChartWidget&lt;/code&gt; (per-field charts)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ScaleChartWidget&lt;/code&gt; (dimension score charts)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TrainExpertWidget&lt;/code&gt; (expert QR code widget)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The Fix: SVG → PNG Conversion
&lt;/h3&gt;

&lt;p&gt;Instead of feeding SVG to iText's fragile renderer, convert SVG to PNG &lt;strong&gt;before&lt;/strong&gt; PDF generation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In WidgetEntity.toHtml()&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isForPdf&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// PDF mode: convert SVG to PNG data URL&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;pngDataUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SvgToPngConverter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;convertToPngDataUrl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;svgContent&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"&amp;lt;img src=\""&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;pngDataUrl&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"\" /&amp;gt;"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Browser mode: keep SVG as-is&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"&amp;lt;img src=\"data:image/svg+xml;base64,"&lt;/span&gt; 
           &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;Base64&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;encode&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;svgContent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getBytes&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"\" /&amp;gt;"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;context.isForPdf()&lt;/code&gt; flag is set by the report engine when generating PDFs. Every widget that produces SVG checks this flag and routes to the PNG converter.&lt;/p&gt;

&lt;h3&gt;
  
  
  The SvgToPngConverter
&lt;/h3&gt;

&lt;p&gt;The converter uses Batik (the same library iText uses internally, but in standalone mode):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;convertToPngDataUrl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;svgContent&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Parse SVG with Batik&lt;/span&gt;
    &lt;span class="nc"&gt;SAXSVGDocumentFactory&lt;/span&gt; &lt;span class="n"&gt;factory&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;SAXSVGDocumentFactory&lt;/span&gt;&lt;span class="o"&gt;(...);&lt;/span&gt;
    &lt;span class="nc"&gt;SVGDocument&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;createSVG&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StringReader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;svgContent&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

    &lt;span class="c1"&gt;// Render to BufferedImage&lt;/span&gt;
    &lt;span class="nc"&gt;SVGConverter&lt;/span&gt; &lt;span class="n"&gt;converter&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;SVGConverter&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;converter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setDestinationType&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DestinationType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PNG&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ... configure dimensions from viewBox&lt;/span&gt;

    &lt;span class="c1"&gt;// Convert to base64 data URL&lt;/span&gt;
    &lt;span class="nc"&gt;ByteArrayOutputStream&lt;/span&gt; &lt;span class="n"&gt;baos&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;ByteArrayOutputStream&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="nc"&gt;ImageIO&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bufferedImage&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"PNG"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;baos&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"data:image/png;base64,"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;Base64&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;encode&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;baos&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toByteArray&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key insight: Batik standalone (for PNG conversion) is more stable than Batik embedded in iText (for direct SVG rendering). The same SVG that crashes iText's PdfArray parser renders fine as a standalone PNG.&lt;/p&gt;

&lt;h3&gt;
  
  
  The viewBox Dimension Bug
&lt;/h3&gt;

&lt;p&gt;But the PNG converter had its own bug. SVGs specify their dimensions through &lt;code&gt;viewBox&lt;/code&gt; (e.g., &lt;code&gt;viewBox="0 0 280 64"&lt;/code&gt;). The converter was ignoring viewBox and using default full-page dimensions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SVG viewBox: 280×64 (wide and short)
PNG output:  1024×768 (default, wrong aspect ratio)
→ Chart renders as a tiny strip in the middle of a huge transparent PNG
→ PDF shows a microscopic chart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix: extract width and height from &lt;code&gt;viewBox&lt;/code&gt; and use them for PNG dimensions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Parse viewBox="minX minY width height"&lt;/span&gt;
&lt;span class="nc"&gt;Pattern&lt;/span&gt; &lt;span class="n"&gt;vbPattern&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Pattern&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;compile&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"viewBox\\s*=\\s*['\"]([^'\"]+)['\"]"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;Matcher&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;vbPattern&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;matcher&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;svgContent&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;find&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;split&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"\\s+"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;vbWidth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Float&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parseFloat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;]);&lt;/span&gt;
    &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;vbHeight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Float&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parseFloat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="o"&gt;]);&lt;/span&gt;
    &lt;span class="c1"&gt;// Use viewBox dimensions, scaled up for resolution&lt;/span&gt;
    &lt;span class="n"&gt;converter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setWidth&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vbWidth&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// 2x for retina-quality PNG&lt;/span&gt;
    &lt;span class="n"&gt;converter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setHeight&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vbHeight&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Battle 2: Emoji Rendering in PDF
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Bug
&lt;/h3&gt;

&lt;p&gt;The report HTML contains emoji — 🎉 in congratulations, 📊 in chart titles, ✅ in checklists. In the browser, these render fine. In the PDF, they show as &lt;strong&gt;blank squares&lt;/strong&gt; (tofu) or disappear entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Root Cause
&lt;/h3&gt;

&lt;p&gt;iText uses font registration to render text. The default fonts (Helvetica, Times-Roman) don't include emoji glyphs. When iText encounters an emoji codepoint, it looks for a font that covers it, finds none, and renders nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Font Routing Problem
&lt;/h3&gt;

&lt;p&gt;The solution sounds simple: register an emoji font (like Noto Color Emoji or Apple Color Emoji). But it's not that simple because:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Color emoji fonts&lt;/strong&gt; (Noto Color Emoji, Apple Color Emoji) use bitmap/COLR tables. iText's font engine doesn't fully support color emoji — it renders them as monochrome or not at all.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Monochrome emoji fonts&lt;/strong&gt; (Symbola, DejaVu Sans) cover emoji codepoints but render them as simple black-and-white symbols. Better than nothing, but not great.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Font fallback chains&lt;/strong&gt; — iText needs explicit font registration for each Unicode range. There's no automatic fallback like browsers have.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The Solution: Font Coverage Matrix
&lt;/h3&gt;

&lt;p&gt;Register multiple fonts covering different Unicode ranges:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;PdfFontFactory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/fonts/NotoSans-Regular.ttf"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"noto-sans"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;PdfFontFactory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/fonts/Symbola.ttf"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"symbola-emoji"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// ... other fonts&lt;/span&gt;

&lt;span class="c1"&gt;// Font routing: for each character, find a font that has it&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;PdfFont&lt;/span&gt; &lt;span class="nf"&gt;getFontForChar&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Character&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isSupplementaryCodePoint&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Emoji range (U+1F000-U+1FAFF, U+2600-U+27BF)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isEmojiCodepoint&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;symbolaEmojiFont&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// Monochrome but visible&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;defaultFont&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// Noto Sans for regular text&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The approach is imperfect — emoji render as black-and-white symbols, not color. But they're &lt;strong&gt;visible&lt;/strong&gt;, which is the minimum acceptable outcome. A blank square where "🎉" should be is worse than a monochrome party popper.&lt;/p&gt;

&lt;h3&gt;
  
  
  The &lt;code&gt;setSplitCharacter&lt;/code&gt; Fix
&lt;/h3&gt;

&lt;p&gt;Even with the right font registered, iText would sometimes split emoji codepoints across lines, corrupting them. Emoji are often represented as surrogate pairs (two &lt;code&gt;char&lt;/code&gt; values). iText's default splitter treats each &lt;code&gt;char&lt;/code&gt; independently, splitting surrogate pairs.&lt;/p&gt;

&lt;p&gt;The fix: a custom &lt;code&gt;ISplitCharacter&lt;/code&gt; that doesn't split surrogate pairs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EmojiSafeSplitCharacter&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ISplitCharacter&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;isSplitCharacter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; 
                                     &lt;span class="kt"&gt;char&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;cc&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;PdfChunk&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;ck&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Don't split between surrogate pairs&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Character&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isHighSurrogate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cc&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="o"&gt;])&lt;/span&gt; 
            &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;cc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt; 
            &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nc"&gt;Character&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isLowSurrogate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cc&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;]))&lt;/span&gt; &lt;span class="o"&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="o"&gt;;&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;defaultSplit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isSplitCharacter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cc&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ck&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Battle 3: Background Image SVG Compatibility
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Bug
&lt;/h3&gt;

&lt;p&gt;Report pages with SVG background images would render with a &lt;strong&gt;black opaque rectangle&lt;/strong&gt; covering the entire page in PDF mode. The content underneath was hidden.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Root Cause
&lt;/h3&gt;

&lt;p&gt;The SVG backgrounds used &lt;code&gt;&amp;lt;rect&amp;gt;&lt;/code&gt; elements with &lt;code&gt;fill&lt;/code&gt; for the background color. In browser mode, these render as semi-transparent overlays. In iText's SVG renderer, &lt;code&gt;fill-opacity&lt;/code&gt; and &lt;code&gt;rgba()&lt;/code&gt; opacity are partially supported — the opacity is ignored, and the fill renders as fully opaque.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;fill="rgba(0,0,0,0.6)"&lt;/code&gt; (semi-transparent dark overlay) becomes &lt;code&gt;fill="rgb(0,0,0)"&lt;/code&gt; (solid black) in PDF.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix: Background SVG Cleaning
&lt;/h3&gt;

&lt;p&gt;A "washing" step before PDF rendering that removes or adjusts opacity-incompatible elements:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;cleanSvgForPdf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;svg&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Remove &amp;lt;rect&amp;gt; background elements (they render as opaque in PDF)&lt;/span&gt;
    &lt;span class="n"&gt;svg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;svg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;replaceAll&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"&amp;lt;rect[^&amp;gt;]*fill=\"rgba\\([^)]+\\)\"[^&amp;gt;]*/&amp;gt;"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Replace rgba() with solid color + reduced opacity attribute&lt;/span&gt;
    &lt;span class="c1"&gt;// (iText handles opacity attribute better than rgba())&lt;/span&gt;

    &lt;span class="c1"&gt;// Remove CSS background-image references&lt;/span&gt;
    &lt;span class="n"&gt;svg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;svg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;replaceAll&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"background-image:\\s*url\\([^)]+\\)"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;svg&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This runs before the SVG→PNG conversion, ensuring the background renders correctly.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Batik Namespace Requirement
&lt;/h3&gt;

&lt;p&gt;One more SVG gotcha: Batik requires the SVG to declare the standard namespace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Without namespace: Batik throws "not a valid SVG document" --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;svg&lt;/span&gt; &lt;span class="na"&gt;viewBox=&lt;/span&gt;&lt;span class="s"&gt;"0 0 280 64"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  ...
&lt;span class="nt"&gt;&amp;lt;/svg&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- With namespace: works --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;svg&lt;/span&gt; &lt;span class="na"&gt;xmlns=&lt;/span&gt;&lt;span class="s"&gt;"http://www.w3.org/2000/svg"&lt;/span&gt; &lt;span class="na"&gt;viewBox=&lt;/span&gt;&lt;span class="s"&gt;"0 0 280 64"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  ...
&lt;span class="nt"&gt;&amp;lt;/svg&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix: ensure all generated SVG includes &lt;code&gt;xmlns="http://www.w3.org/2000/svg"&lt;/code&gt;. The SKILL.md for the connect module now includes this as a P0 rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  The forPdf Context Flag
&lt;/h2&gt;

&lt;p&gt;All these fixes hang off one flag: &lt;code&gt;context.isForPdf()&lt;/code&gt;. This flag flows through the entire rendering pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RenderContext&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;forPdf&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;isForPdf&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;forPdf&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// In PDF generation:&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setForPdf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;widget&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toHtml&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// Widgets check isForPdf()&lt;/span&gt;
    &lt;span class="c1"&gt;// → SVG becomes PNG data URL&lt;/span&gt;
    &lt;span class="c1"&gt;// → Opacity-incompatible elements cleaned&lt;/span&gt;
    &lt;span class="c1"&gt;// → Font routing uses emoji-safe fonts&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every widget's &lt;code&gt;toHtml()&lt;/code&gt; method checks this flag and routes accordingly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ChartWidget.toHtml()&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isForPdf&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;svgData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SvgToPngConverter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;convertToPngDataUrl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;svgContent&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;svgData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"data:image/svg+xml;base64,"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;Base64&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;encode&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;svgContent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getBytes&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Background SVG&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isForPdf&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;svgContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SvgCleaner&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;cleanForPdf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;svgContent&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This dual-path rendering ensures the same widget HTML works in both browser and PDF, with each path optimized for its target.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Don't trust iText's SVG renderer.&lt;/strong&gt; Convert SVG to PNG before feeding it to iText. Batik standalone is more stable than Batik-in-iText.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;viewBox dimensions are not optional.&lt;/strong&gt; Without explicit dimensions from viewBox, PNG conversion produces wrong-sized images. Always parse and use viewBox.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Emoji in PDF is a font routing problem, not a rendering problem.&lt;/strong&gt; You need a font that covers the codepoints, and you need to prevent surrogate pair splitting. Color emoji may not be achievable, but visible monochrome emoji is good enough.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Opacity doesn't survive PDF conversion.&lt;/strong&gt; &lt;code&gt;rgba()&lt;/code&gt; and &lt;code&gt;fill-opacity&lt;/code&gt; are partially or unsupported. Clean SVG backgrounds before rendering.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The &lt;code&gt;forPdf&lt;/code&gt; flag is the cleanest routing mechanism.&lt;/strong&gt; Instead of maintaining separate HTML templates for browser and PDF, use one template with conditional rendering based on the target.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Series Conclusion
&lt;/h2&gt;

&lt;p&gt;This 10-part series covered the full architecture of a modular assessment engine:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Architecture overview&lt;/strong&gt; — six modules, one plan agent, streaming execution&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Planning engine&lt;/strong&gt; — decision matrix, YAML task list, knowledge self-containment&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Form module&lt;/strong&gt; — field types, inline scoring, reverse questions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scale module&lt;/strong&gt; — dimension direction, range continuity, storage-time reversal&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connect module&lt;/strong&gt; — cover page conversion, visual styles, SVG illustrations&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Report module&lt;/strong&gt; — page templates, widget grid, variable system, logic tiers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expert module&lt;/strong&gt; — prompt framework, behavior red lines, PATCH mode&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One-click pipeline&lt;/strong&gt; — four-phase SSE stream, loading UX, input modes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSE reliability&lt;/strong&gt; — ReentrantLock, virtual threads, timeout handling&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PDF rendering&lt;/strong&gt; — SVG→PNG conversion, emoji font routing, opacity cleaning&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The core architectural decisions that made this work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AI generates CLI commands, not code&lt;/strong&gt; — safer, more controllable, easier to validate&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Each skill is independent&lt;/strong&gt; — self-contained knowledge, no cross-task references&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Templates are engine-computed&lt;/strong&gt; — the AI declares intent, the engine builds structure&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Streaming execution&lt;/strong&gt; — commands run as they're generated, cutting time by 40%&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dual-path rendering&lt;/strong&gt; — one HTML template, conditional browser/PDF output&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building something similar — AI-driven code generation, modular skill systems, or PDF rendering pipelines — I hope this series saves you some of the pain I went through.&lt;/p&gt;




</description>
      <category>ai</category>
      <category>webdev</category>
      <category>productivity</category>
      <category>rpa</category>
    </item>
    <item>
      <title>[9/10]SSE Reliability: Three Rounds of Fixing Event Loss in JAX-RS SseEventSink</title>
      <dc:creator>Keo Fung | FormLM</dc:creator>
      <pubDate>Sat, 18 Jul 2026 05:11:24 +0000</pubDate>
      <link>https://dev.to/keofung/910sse-reliability-three-rounds-of-fixing-event-loss-in-jax-rs-sseeventsink-1857</link>
      <guid>https://dev.to/keofung/910sse-reliability-three-rounds-of-fixing-event-loss-in-jax-rs-sseeventsink-1857</guid>
      <description>&lt;p&gt;&lt;strong&gt;Series: Building a Modular Assessment Engine (9/10)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Server-Sent Events (SSE) is the backbone of the streaming pipeline. When it works, it's elegant — events flow from server to client in real-time. When it breaks, events silently vanish and the user sees a loading spinner forever. This post covers the three rounds of debugging that made SSE reliable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;The backend uses JAX-RS (Jakarta REST) with &lt;code&gt;SseEventSink&lt;/code&gt; for SSE streaming:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@POST&lt;/span&gt;
&lt;span class="nd"&gt;@Produces&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MediaType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SERVER_SENT_EVENTS&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Consumes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MediaType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;APPLICATION_JSON&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Context&lt;/span&gt; &lt;span class="nc"&gt;Sse&lt;/span&gt; &lt;span class="n"&gt;sse&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nd"&gt;@Context&lt;/span&gt; &lt;span class="nc"&gt;SseEventSink&lt;/span&gt; &lt;span class="n"&gt;eventSink&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                 &lt;span class="nd"&gt;@Context&lt;/span&gt; &lt;span class="nc"&gt;HttpServletRequest&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;AgentRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;AgentClient&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ExpertBuilder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;clientId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;appId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;eventSink&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;eventSink&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sse&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Plan or execute...&lt;/span&gt;
    &lt;span class="c1"&gt;// Events sent via client.sendToClient()&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Events are sent through &lt;code&gt;AgentClient.sendToClient()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;sendToClient&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;MediaType&lt;/span&gt; &lt;span class="n"&gt;mediaType&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hasEventSink&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="nc"&gt;OutboundSseEvent&lt;/span&gt; &lt;span class="n"&gt;sseEvent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sse&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newEventBuilder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;mediaType&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mediaType&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;eventSink&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sseEvent&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// ← THE PROBLEM IS HERE&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Round 1: The Missing &lt;code&gt;await&lt;/code&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Bug
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;eventSink.send()&lt;/code&gt; returns &lt;code&gt;CompletionStage&amp;lt;?&amp;gt;&lt;/code&gt; — it's &lt;strong&gt;asynchronous&lt;/strong&gt;. The I/O write happens on a different thread. But the original code discarded the return value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;eventSink&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sseEvent&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// Fire and forget&lt;/span&gt;
&lt;span class="c1"&gt;// ... immediately continues to next line&lt;/span&gt;
&lt;span class="c1"&gt;// ... eventually calls client.close()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;close()&lt;/code&gt; call would execute before the I/O write completed. The connection would close, and the last few events (including the critical &lt;code&gt;done&lt;/code&gt; event) would be lost.&lt;/p&gt;

&lt;p&gt;The client would sit there waiting for a &lt;code&gt;done&lt;/code&gt; event that was never actually sent over the wire.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix (Attempt 1)
&lt;/h3&gt;

&lt;p&gt;Add &lt;code&gt;.toCompletableFuture().join()&lt;/code&gt; to wait for the write to complete:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;eventSink&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sseEvent&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;toCompletableFuture&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;join&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This worked... until it didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 2: The Concurrent Write Crash
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Bug
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;join()&lt;/code&gt; in place, a new problem emerged: &lt;code&gt;UT010035: Stream in async mode was not ready for IO operation&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This happened because &lt;strong&gt;two threads were calling &lt;code&gt;send()&lt;/code&gt; simultaneously&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The main execution thread sending task events&lt;/li&gt;
&lt;li&gt;A heartbeat thread sending periodic heartbeats every 10 seconds&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;JAX-RS &lt;code&gt;SseEventSink&lt;/code&gt; is not thread-safe for concurrent writes. Two simultaneous &lt;code&gt;send()&lt;/code&gt; calls corrupt the internal buffer state.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix (Attempt 2)
&lt;/h3&gt;

&lt;p&gt;Add a &lt;code&gt;synchronized&lt;/code&gt; block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;sendToClient&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;MediaType&lt;/span&gt; &lt;span class="n"&gt;mediaType&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hasEventSink&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;synchronized&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hasEventSink&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;eventSink&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sseEvent&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;toCompletableFuture&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;join&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This fixed the concurrent write crash... but introduced a new problem on JDK 21+.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 3: The Virtual Thread Pinning
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Bug
&lt;/h3&gt;

&lt;p&gt;The system runs on JDK 21 with &lt;strong&gt;virtual threads&lt;/strong&gt;. Virtual threads are lightweight, user-mode threads that should never block the carrier thread. But &lt;code&gt;synchronized&lt;/code&gt; blocks &lt;strong&gt;pin&lt;/strong&gt; the virtual thread to its carrier thread — the carrier can't be freed for other virtual threads while one is blocked inside a &lt;code&gt;synchronized&lt;/code&gt; block.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;synchronized(this)&lt;/code&gt; + &lt;code&gt;join()&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The heartbeat thread acquires the lock, starts an I/O write, calls &lt;code&gt;join()&lt;/code&gt; which blocks&lt;/li&gt;
&lt;li&gt;The carrier thread is now pinned — it can't run other virtual threads&lt;/li&gt;
&lt;li&gt;The main execution thread tries to send an event, blocks on the synchronized lock&lt;/li&gt;
&lt;li&gt;All virtual threads on this carrier are now stuck&lt;/li&gt;
&lt;li&gt;Under load, the entire thread pool exhausts&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The application would freeze after 3-4 concurrent users.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Final Fix: ReentrantLock + get(timeout)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ReentrantLock&lt;/span&gt; &lt;span class="n"&gt;writeLock&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;ReentrantLock&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;sendToClient&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;MediaType&lt;/span&gt; &lt;span class="n"&gt;mediaType&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hasEventSink&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;writeLock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;lock&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Double-check: connection may have closed while waiting for lock&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hasEventSink&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

        &lt;span class="nc"&gt;OutboundSseEvent&lt;/span&gt; &lt;span class="n"&gt;sseEvent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sse&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newEventBuilder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;mediaType&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mediaType&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

        &lt;span class="c1"&gt;// Wait for I/O completion with timeout&lt;/span&gt;
        &lt;span class="n"&gt;eventSink&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sseEvent&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;toCompletableFuture&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;TimeUnit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SECONDS&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;lastSendTimeMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;currentTimeMillis&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TimeoutException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// I/O didn't complete in 10s — connection is likely dead&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;close&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Unwrap CompletionException to get real cause&lt;/span&gt;
        &lt;span class="nc"&gt;Throwable&lt;/span&gt; &lt;span class="n"&gt;cause&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCause&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCause&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cause&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;IllegalStateException&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// Connection already closed&lt;/span&gt;
            &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;close&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;FLog&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;e&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;TAG&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"SSE send failed"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cause&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;close&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;writeLock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;unlock&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why ReentrantLock Instead of synchronized
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ReentrantLock&lt;/code&gt; doesn't pin virtual threads. When a virtual thread blocks on &lt;code&gt;ReentrantLock.lock()&lt;/code&gt;, the JVM can unmount it from the carrier thread, freeing the carrier for other work. With &lt;code&gt;synchronized&lt;/code&gt;, the JVM must keep the virtual thread mounted — this is the "pinning" problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why &lt;code&gt;get(10, SECONDS)&lt;/code&gt; Instead of &lt;code&gt;join()&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;join()&lt;/code&gt; blocks indefinitely. If the network connection is half-open (TCP alive but SSE broken), &lt;code&gt;join()&lt;/code&gt; waits forever, holding the lock. No other thread can send events. The heartbeat thread can't even send a heartbeat to check if the connection is alive.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;get(10, TimeUnit.SECONDS)&lt;/code&gt; adds a timeout. If I/O doesn't complete in 10 seconds, something is wrong. The &lt;code&gt;TimeoutException&lt;/code&gt; handler calls &lt;code&gt;close()&lt;/code&gt;, releasing the lock and cleaning up the connection.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Double-Check &lt;code&gt;hasEventSink()&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Between acquiring the lock and sending, another thread might have closed the connection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Thread A: writeLock.lock() ← acquired
Thread B: close() called, eventSink = null
Thread A: eventSink.send() ← NPE!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The double-check prevents this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Thread A: writeLock.lock() ← acquired
Thread A: if (!hasEventSink()) return ← connection closed by Thread B, exit safely
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Heartbeat Problem
&lt;/h2&gt;

&lt;p&gt;Heartbeats are sent every 10 seconds to keep the SSE connection alive. Without heartbeats, proxies and load balancers close idle connections after 30-60 seconds.&lt;/p&gt;

&lt;p&gt;But heartbeats introduce a new problem: the heartbeat thread and the main thread both call &lt;code&gt;sendToClient()&lt;/code&gt;. With the ReentrantLock, this is safe — one waits for the other. But the heartbeat thread must not block for too long:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Heartbeat thread&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;currentTimeMillis&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lastSendTimeMs&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10_000&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sendToClient&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{\"event\":\"heartbeat\"}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;MediaType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;APPLICATION_JSON_TYPE&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the main thread is mid-write and the heartbeat thread tries to send, it waits on the lock. Once the main thread finishes, the heartbeat sends. Total delay: the duration of one I/O write (usually &amp;lt;1 second). Acceptable.&lt;/p&gt;

&lt;p&gt;But what if the main thread's I/O is stuck? The heartbeat thread waits for the lock, and &lt;code&gt;get(10, SECONDS)&lt;/code&gt; is the main thread's timeout. After 10 seconds, the main thread calls &lt;code&gt;close()&lt;/code&gt;, the lock releases, and the heartbeat thread's &lt;code&gt;sendToClient()&lt;/code&gt; sees &lt;code&gt;hasEventSink() == false&lt;/code&gt; and exits. Clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  The JSON Serialization Bug
&lt;/h2&gt;

&lt;p&gt;One more SSE bug, unrelated to threading but worth mentioning: the &lt;code&gt;ConvertUtil.toJson()&lt;/code&gt; method had a quirk where it didn't properly escape strings containing special characters.&lt;/p&gt;

&lt;p&gt;When a task description contained a quote or newline, the JSON payload would be malformed:&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="nl"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"task.start"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"taskId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"t1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Assess "&lt;/span&gt;&lt;span class="err"&gt;stress&lt;/span&gt;&lt;span class="s2"&gt;" levels"&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;
                                                              &lt;/span&gt;&lt;span class="err"&gt;↑&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;broken&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;JSON&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client's &lt;code&gt;JSON.parse()&lt;/code&gt; would throw, and the event would be silently dropped. The user would see a task that never starts.&lt;/p&gt;

&lt;p&gt;The fix: ensure &lt;code&gt;ConvertUtil.toJson()&lt;/code&gt; properly escapes all string values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before (bug): raw string insertion&lt;/span&gt;
&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;append&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"\"title\":\""&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;append&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTitle&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;append&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"\""&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// After (fixed): proper JSON string escaping&lt;/span&gt;
&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;append&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"\"title\":\""&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;append&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jsonEscape&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTitle&lt;/span&gt;&lt;span class="o"&gt;())).&lt;/span&gt;&lt;span class="na"&gt;append&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"\""&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Nginx Configuration
&lt;/h2&gt;

&lt;p&gt;Behind all the Java code, Nginx needs specific configuration for SSE:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/v1/assess/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_read_timeout&lt;/span&gt; &lt;span class="s"&gt;300s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;# 5 minutes for long-running AI calls&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_buffering&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;# SSE needs unbuffered transfer&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_cache&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Connection&lt;/span&gt; &lt;span class="s"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;chunked_transfer_encoding&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;proxy_buffering off&lt;/code&gt; is critical. With buffering on, Nginx collects events in a buffer and sends them in batches. The client sees nothing for 30 seconds, then gets everything at once. SSE is supposed to be real-time.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;proxy_read_timeout 300s&lt;/code&gt; prevents Nginx from closing the connection during long AI generation. Default is 60s, which is too short for a 30-second planning phase.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;SseEventSink.send()&lt;/code&gt; is async. Always await it.&lt;/strong&gt; Discarding the &lt;code&gt;CompletionStage&lt;/code&gt; means events are lost. Use &lt;code&gt;.get(timeout)&lt;/code&gt; not &lt;code&gt;.join()&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Virtual threads need &lt;code&gt;ReentrantLock&lt;/code&gt;, not &lt;code&gt;synchronized&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;synchronized&lt;/code&gt; pins virtual threads to carrier threads, killing the scalability benefit. This is a JDK 21+ specific issue.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Always timeout.&lt;/strong&gt; Network connections fail in mysterious ways. A timeout is the difference between a recoverable error and a permanently stuck thread.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Double-check after acquiring a lock.&lt;/strong&gt; State can change between lock acquisition and use. The double-check pattern prevents NPEs on closed connections.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;proxy_buffering off&lt;/code&gt; is mandatory for SSE.&lt;/strong&gt; Without it, events arrive in batches, not in real-time. The UX difference is dramatic.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Next: [PDF Rendering War — SVG, emoji, and the iText NPE]&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>productivity</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>[8/10]The One-Click Pipeline: How One SSE Stream Orchestrates Everything</title>
      <dc:creator>Keo Fung | FormLM</dc:creator>
      <pubDate>Thu, 16 Jul 2026 06:57:23 +0000</pubDate>
      <link>https://dev.to/keofung/810the-one-click-pipeline-how-one-sse-stream-orchestrates-everything-3j3h</link>
      <guid>https://dev.to/keofung/810the-one-click-pipeline-how-one-sse-stream-orchestrates-everything-3j3h</guid>
      <description>&lt;p&gt;&lt;strong&gt;Series: Building a Modular Assessment Engine (8/10)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The previous posts covered individual modules. This one covers the glue — the frontend pipeline in &lt;code&gt;newapp.html&lt;/code&gt; that takes a user's single input and drives it through knowledge creation, AI planning, streaming execution, and publishing. All through one SSE connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  The User Experience
&lt;/h2&gt;

&lt;p&gt;The user sees a text box. They type (or paste) their request — a document, a URL, or just a sentence like "build a workplace stress assessment." Two buttons appear:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Review &amp;amp; Edit&lt;/strong&gt; — see the AI's plan before execution&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create Directly&lt;/strong&gt; — skip the plan review, go straight to generation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Either way, the pipeline is the same. The only difference is whether the user sees and edits the plan before execution.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Four-Phase Pipeline
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Phase 1: Generate     → POST /api/v1/fetch/generate
Phase 2: Plan (SSE)   → SSE  /api/v1/assess/v1/chat (plan mode)
Phase 3: Execute (SSE) → SSE /api/v1/assess/v1/chat (execute mode)
Phase 4: Publish      → GET  /api/v1/fetch/share
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Phase 1: Knowledge + App Skeleton
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&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;$server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/v1/fetch/generate&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;name&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;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;reqContent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;// pasted text&lt;/span&gt;
    &lt;span class="na"&gt;sourceUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;reqSourceUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;// URL to scrape&lt;/span&gt;
    &lt;span class="na"&gt;goal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;reqGoal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                 &lt;span class="c1"&gt;// user's intent&lt;/span&gt;
    &lt;span class="na"&gt;knowledgeId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;prefillKnowledgeId&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="na"&gt;contentPreprocessed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hasFiles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// skip backend compression if files pre-processed&lt;/span&gt;
    &lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lang&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This call does three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Creates a knowledge base&lt;/strong&gt; from the input (text, URL, or file attachments)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Creates an app skeleton&lt;/strong&gt; (empty Flower domain object)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Returns structured input&lt;/strong&gt; for the plan agent&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The response includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;appId&lt;/code&gt; — the new app's ID&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;input&lt;/code&gt; — structured input with context tags&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;displayContent&lt;/code&gt; — clean text for the textarea (no tags)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Phase 2: Plan (SSE Stream)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;runPlanPhase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;appId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;option&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startPlanLoadingTimer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;$agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assessChat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;appId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;plan&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;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stopPlanLoadingTimer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentPlan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="c1"&gt;// Either show for review or start execution&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;heartbeat&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;advancePlanLoading&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// rotate loading messages&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Plan generation failed&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;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;close&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="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentPlan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Plan timeout&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plan phase sends user input to the AssessAgent, which:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Loads &lt;code&gt;agents/assess-plan.md&lt;/code&gt; as system prompt&lt;/li&gt;
&lt;li&gt;Dynamically injects available skill list&lt;/li&gt;
&lt;li&gt;Calls the AI model&lt;/li&gt;
&lt;li&gt;Parses the YAML response into an &lt;code&gt;AssessPlan&lt;/code&gt; object&lt;/li&gt;
&lt;li&gt;Streams the plan back as a &lt;code&gt;plan&lt;/code&gt; event&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The Loading Message Problem
&lt;/h3&gt;

&lt;p&gt;AI planning takes 10-30 seconds. During that time, the user stares at a loading spinner. If nothing changes for 15 seconds, they assume it's broken and leave.&lt;/p&gt;

&lt;p&gt;The solution: a &lt;strong&gt;progressive loading message timer&lt;/strong&gt; that rotates messages every 8 seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;steps&lt;/span&gt; &lt;span class="o"&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;newapp.loading.plan&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;newapp.loading.plan_step1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// "Analyzing your request..."&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;newapp.loading.plan_step2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// "Designing assessment dimensions..."&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;newapp.loading.plan_step3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// "Planning scoring logic..."&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;newapp.loading.plan_step4&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;   &lt;span class="c1"&gt;// "Preparing report structure..."&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These messages are &lt;strong&gt;cosmetic&lt;/strong&gt; — they don't reflect actual progress. But they give the user a sense that things are moving. Combined with SSE heartbeats (which advance the message rotation), the user always sees changing content.&lt;/p&gt;

&lt;p&gt;Is this honest? Partially. The messages describe what the AI is conceptually doing (analyzing, designing dimensions, planning scoring) even if we can't track exact progress. It's better than a static spinner.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 3: Execute (SSE Stream)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;startExecute&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;executing&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;plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;({},&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentPlan&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Initialize progress tracking&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;progressTasks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;t&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;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="na"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;taskId&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="p"&gt;}));&lt;/span&gt;

    &lt;span class="c1"&gt;// Execute via SSE&lt;/span&gt;
    &lt;span class="nx"&gt;$agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assessChat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;appId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;evData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onExecuteEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;evData&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The execute phase drives the AssessAgent's &lt;code&gt;execute()&lt;/code&gt; method. Events flow back:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;th&gt;When&lt;/th&gt;
&lt;th&gt;UI Update&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;task.start&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A module begins&lt;/td&gt;
&lt;td&gt;Mark task as "running"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cmd.done&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A CLI command completes&lt;/td&gt;
&lt;td&gt;Update task description with result&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;task.done&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A module finishes&lt;/td&gt;
&lt;td&gt;Mark task as "done"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;task.error&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A module fails&lt;/td&gt;
&lt;td&gt;Mark task as "error"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;share.url&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Share URL generated&lt;/td&gt;
&lt;td&gt;Store for final display&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;plan.done&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;All modules complete&lt;/td&gt;
&lt;td&gt;Trigger completion sequence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;done&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Final event with credits&lt;/td&gt;
&lt;td&gt;Accumulate credit cost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;close&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SSE connection closes&lt;/td&gt;
&lt;td&gt;Finalize if still executing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The SSE Event Processing
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;onExecuteEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;task.start&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;data&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;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findProgTask&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;taskId&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;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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;running&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;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cmd.done&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;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Update task with latest command result&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findProgTask&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;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;taskId&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;ct&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;result&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;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&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="nx"&gt;l&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nx"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;substring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&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="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;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;share.url&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;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shareUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;plan.done&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onPlanDone&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;close&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stage&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;executing&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onPlanDone&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// SSE closed, finalize&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 event updates the UI in real-time. The user sees:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;"Designing form..." → running&lt;/li&gt;
&lt;li&gt;"Created field: workload_1" → description updates&lt;/li&gt;
&lt;li&gt;"Created field: workload_2" → description updates&lt;/li&gt;
&lt;li&gt;"Form complete" → task done&lt;/li&gt;
&lt;li&gt;"Configuring scoring..." → next task starts&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This transparency builds trust. The user can see the AI working, not just a spinner.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 4: Completion
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;onPlanDone&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isDone&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isDone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Mark all pending tasks as done&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;progressTasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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;pending&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;t&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;running&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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;done&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="c1"&gt;// Fetch share URL if not already received&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shareUrl&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;appId&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;res&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;$server&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;/api/v1/fetch/share?appId=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;appId&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;res&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;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shareUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shareUrl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;appUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;appUrl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Switch to done screen after 600ms&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="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;done&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;$nextTick&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;renderShareQr&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;600&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 600ms delay is deliberate — it lets the last progress animation complete before the screen switches. Without it, the transition feels jarring.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Knowledge Base Input Modes
&lt;/h2&gt;

&lt;p&gt;The pipeline handles four input modes, each with different preprocessing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Processing&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Text paste&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;content&lt;/code&gt; field&lt;/td&gt;
&lt;td&gt;Backend AI compresses and structures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;URL scrape&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sourceUrl&lt;/code&gt; field&lt;/td&gt;
&lt;td&gt;Backend fetches + extracts content&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;File upload&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;content&lt;/code&gt; from files&lt;/td&gt;
&lt;td&gt;Frontend AI pre-formats, &lt;code&gt;contentPreprocessed=true&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Knowledge base&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;knowledgeId&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Backend reads existing KB, no new creation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pure text (no KB)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;goal&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;No KB created, app generated from goal alone&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;contentPreprocessed&lt;/code&gt; flag is important: when files are uploaded, the frontend already runs an AI extraction step to format the content. If the backend tries to compress it again, it double-processes and loses information. The flag tells the backend to skip compression.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PlanType Override
&lt;/h2&gt;

&lt;p&gt;Users can optionally specify a plan type before generation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;directInput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;genData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;planConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;planType&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;directInput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;planType=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;planConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;planType&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;directInput&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 injects a constraint directive at the top of the input. The plan agent sees it and respects it — if the user says &lt;code&gt;planType=exam&lt;/code&gt;, the agent generates an exam sequence even if the content could work as an assessment.&lt;/p&gt;

&lt;p&gt;Without this, the plan agent might choose &lt;code&gt;assessment&lt;/code&gt; for a knowledge test, which would use Likert scales instead of correct/incorrect marking. Wrong plan type = wrong entire app structure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The SSE Connection Lifecycle
&lt;/h2&gt;

&lt;p&gt;The SSE connection lives across both plan and execute phases. In plan mode, it's a read-only stream — the client listens for the &lt;code&gt;plan&lt;/code&gt; event. In execute mode, the client sends the plan object and listens for task events.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Plan phase: input only, no plan&lt;/span&gt;
&lt;span class="nx"&gt;$agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assessChat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;appId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Execute phase: plan only, no input  &lt;/span&gt;
&lt;span class="nx"&gt;$agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assessChat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;appId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The backend distinguishes the two by checking &lt;code&gt;request.hasPlan()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hasPlan&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Execute phase&lt;/span&gt;
    &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;execute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPlan&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;listener&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Plan phase&lt;/span&gt;
    &lt;span class="nc"&gt;AssessPlan&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;plan&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;sendChatEvent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"plan"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ConvertUtil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toJson&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Close Event Ambiguity
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;close&lt;/code&gt; event is ambiguous — it fires when the SSE connection closes, which can happen:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Normally&lt;/strong&gt; — after all events are sent&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Due to timeout&lt;/strong&gt; — network issue or server timeout&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Due to error&lt;/strong&gt; — unhandled exception on the server&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The frontend handles this by checking state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;close&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stage&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;executing&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onPlanDone&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// SSE closed during execution, assume done&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// If stage === 'planning', the plan-phase close handler already fires onError&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the connection closes during planning (before a plan is received), it's an error. If it closes during execution, it's treated as completion — the server may have finished sending all events before closing.&lt;/p&gt;

&lt;p&gt;This is imperfect. A server crash during execution would be treated as successful completion. But the alternative — treating all closes as errors — would generate false error messages when the server cleanly finishes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Loading messages must rotate.&lt;/strong&gt; A static spinner for 20+ seconds kills conversion. Even cosmetic progress helps.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Input mode matters.&lt;/strong&gt; Different input types (text, URL, files, KB) need different preprocessing. Don't double-process.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;SSE event types must be unambiguous.&lt;/strong&gt; &lt;code&gt;close&lt;/code&gt; during planning ≠ &lt;code&gt;close&lt;/code&gt; during execution. Check application state before interpreting events.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The 600ms completion delay is a UX detail that matters.&lt;/strong&gt; Instant screen switches after long loading feel jarring. A small delay makes the transition feel natural.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Plan type override is a safety valve.&lt;/strong&gt; When the AI picks the wrong plan type, the user can force it. This single feature prevented countless support tickets.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Next: [SSE Reliability — Three rounds of fixing event loss]&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>productivity</category>
      <category>automation</category>
    </item>
    <item>
      <title>[7/10]Giving AI Assistants a Personality: Role, Boundaries, and Contextual Awareness</title>
      <dc:creator>Keo Fung | FormLM</dc:creator>
      <pubDate>Thu, 16 Jul 2026 06:56:43 +0000</pubDate>
      <link>https://dev.to/keofung/710giving-ai-assistants-a-personality-role-boundaries-and-contextual-awareness-2lk0</link>
      <guid>https://dev.to/keofung/710giving-ai-assistants-a-personality-role-boundaries-and-contextual-awareness-2lk0</guid>
      <description>&lt;p&gt;&lt;strong&gt;Series: Building a Modular Assessment Engine (7/10)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The expert module configures an AI chatbot that users can interact with after completing an assessment. Not a generic "ask me anything" bot — one that knows the user's assessment results and stays within professional boundaries. This post covers the prompt engineering, the behavior red lines, and the consistency problems that made early versions dangerous.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Configuration Surface
&lt;/h2&gt;

&lt;p&gt;The expert is configured through a single &lt;code&gt;config&lt;/code&gt; command plus an &lt;code&gt;avatar&lt;/code&gt; command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;assess expert config &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Stress Coach"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role&lt;/span&gt; &lt;span class="s2"&gt;"Senior Workplace Psychological Counselor"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--description&lt;/span&gt; &lt;span class="s2"&gt;"Helps you analyze stress sources and provides personalized advice."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--style&lt;/span&gt; &lt;span class="s2"&gt;"Warm, empathetic, supportive, and inspiring"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--welcome&lt;/span&gt; &lt;span class="s2"&gt;"Hi! I'm your stress coach. I can see your results across Workload, Role Stress, and Social Support. What would you like to discuss first?"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--question1&lt;/span&gt; &lt;span class="s2"&gt;"What do my scores mean?"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--question2&lt;/span&gt; &lt;span class="s2"&gt;"How can I reduce workload stress?"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--question3&lt;/span&gt; &lt;span class="s2"&gt;"How do I talk to my manager about this?"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--theme&lt;/span&gt; &lt;span class="s2"&gt;"Warm"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--kbText&lt;/span&gt; &lt;span class="s2"&gt;"Workplace stress is caused by..."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--prompt&lt;/span&gt; &lt;span class="s2"&gt;"【Role】You are a senior counselor with 10 years of experience...
    【Knowledge】Based on the user's assessment results...
    【Communication】Warm, empathetic, uses guiding questions...
    【Boundaries】1. Never provide medical or psychiatric diagnosis.
    2. If user shows extreme distress, gently suggest professional help.
    3. Keep responses under 200 words."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enable&lt;/span&gt;

assess expert avatar &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"/page/assets/img/avatar/a4.png"&lt;/span&gt; &lt;span class="nt"&gt;--enable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's 12 parameters in one command. Omitting any of them leaves the assistant with a gap — no name, no welcome message, no behavior boundaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Four-Element Prompt Framework
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;--prompt&lt;/code&gt; field is the system prompt for the chatbot. It must contain four elements:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Role Definition
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;"You are a senior workplace psychological counselor with 10 years of experience in cognitive behavioral therapy and stress management."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Specific, not generic. "You are a helpful assistant" is useless. "You are a senior counselor with CBT experience" gives the AI a professional frame.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Knowledge Boundary
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;"Based on the user's Workplace Stress Assessment results, analyze their scores across Workload, Role Stress, and Social Support dimensions."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This tells the AI what it knows and what it doesn't. It knows the assessment results. It doesn't know the user's medical history. It doesn't have access to real-time data. The knowledge boundary prevents hallucination.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Communication Style
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;"Tone is warm, empathetic, and supportive. Use guiding questions. Avoid lecturing."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The style must match the &lt;code&gt;--style&lt;/code&gt; parameter. If &lt;code&gt;--style&lt;/code&gt; says "professional and rigorous," the prompt shouldn't say "casual and fun." Consistency between these two fields was a recurring bug — the AI would set &lt;code&gt;--style "warm"&lt;/code&gt; but write a prompt that says "maintain professional distance."&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Behavior Red Lines
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;"1. Never provide medical or psychiatric diagnosis.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;If user shows extreme negative emotions, gently suggest seeking professional medical help.&lt;/li&gt;
&lt;li&gt;Keep each response under 200 words."&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;

&lt;p&gt;Red lines are the most important element. Without them, the AI will happily diagnose depression, recommend medication, or give legal advice. Each red line is a specific, testable constraint.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Welcome Message Dimension Problem
&lt;/h2&gt;

&lt;p&gt;The welcome message must reference the assessment dimensions by name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✅ "I can see your results across Workload, Role Stress, and Social Support."
❌ "I can see your assessment results."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why? Because generic welcome messages feel impersonal. Naming the dimensions tells the user "I actually know your specific results, not just that you took some test."&lt;/p&gt;

&lt;p&gt;The trap: the dimension names must come from the &lt;strong&gt;scale-dimensions reference&lt;/strong&gt;, not from the plan agent's knowledge field. The plan agent might say "Workload" but the scale module might have created "Workload Intensity." The welcome message must use the exact name from the scale data.&lt;/p&gt;

&lt;p&gt;The SKILL.md enforces this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Welcome message dimension names MUST be read verbatim from the &lt;code&gt;scale-dimensions&lt;/code&gt; reference &lt;code&gt;## Scales&lt;/code&gt; table &lt;code&gt;name&lt;/code&gt; column. Never guess from knowledge.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The PATCH Problem
&lt;/h2&gt;

&lt;p&gt;When modifying an existing expert configuration, the AI would frequently rewrite everything — new name, new role, new prompt — even when the user only asked to change the welcome message.&lt;/p&gt;

&lt;p&gt;The fix: a &lt;code&gt;[CURRENT]&lt;/code&gt; / &lt;code&gt;[PATCH]&lt;/code&gt; protocol in the knowledge field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;[&lt;span class="n"&gt;CURRENT&lt;/span&gt;]
&lt;span class="n"&gt;name&lt;/span&gt;: &lt;span class="n"&gt;Stress&lt;/span&gt; &lt;span class="n"&gt;Coach&lt;/span&gt;
&lt;span class="n"&gt;role&lt;/span&gt;: &lt;span class="n"&gt;Senior&lt;/span&gt; &lt;span class="n"&gt;Counselor&lt;/span&gt;
&lt;span class="n"&gt;style&lt;/span&gt;: &lt;span class="n"&gt;Warm&lt;/span&gt;, &lt;span class="n"&gt;empathetic&lt;/span&gt;
&lt;span class="n"&gt;welcome&lt;/span&gt;: &lt;span class="n"&gt;Hi&lt;/span&gt;! &lt;span class="n"&gt;I&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="n"&gt;your&lt;/span&gt; &lt;span class="n"&gt;stress&lt;/span&gt; &lt;span class="n"&gt;coach&lt;/span&gt;...
&lt;span class="n"&gt;question1&lt;/span&gt;: &lt;span class="n"&gt;What&lt;/span&gt; &lt;span class="n"&gt;do&lt;/span&gt; &lt;span class="n"&gt;my&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="n"&gt;mean&lt;/span&gt;?
...

[&lt;span class="n"&gt;PATCH&lt;/span&gt;]
&lt;span class="n"&gt;Update&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;welcome&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;be&lt;/span&gt; &lt;span class="n"&gt;more&lt;/span&gt; &lt;span class="n"&gt;casual&lt;/span&gt; &lt;span class="n"&gt;and&lt;/span&gt; &lt;span class="n"&gt;friendly&lt;/span&gt;.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule: &lt;strong&gt;only modify what &lt;code&gt;[PATCH]&lt;/code&gt; explicitly mentions.&lt;/strong&gt; Everything in &lt;code&gt;[CURRENT]&lt;/code&gt; must be preserved verbatim. The AI reads the current config, applies only the patch, and outputs the full config with the changes merged in.&lt;/p&gt;

&lt;p&gt;This sounds obvious, but without explicit enforcement, the AI would "improve" the role description while updating the welcome message, or rewrite the behavior red lines because they "could be better phrased."&lt;/p&gt;

&lt;h2&gt;
  
  
  The Theme Mismatch
&lt;/h2&gt;

&lt;p&gt;The expert has its own &lt;code&gt;--theme&lt;/code&gt; parameter (same 12 presets as connect). The rule: &lt;strong&gt;it must match the connect/report theme.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the assessment uses &lt;code&gt;Noir&lt;/code&gt; (dark professional), the expert chat should also be &lt;code&gt;Noir&lt;/code&gt;. A &lt;code&gt;Fresh&lt;/code&gt; (light green) expert chat inside a &lt;code&gt;Noir&lt;/code&gt; assessment feels like walking from a dimly lit room into a fluorescent-lit hallway.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;lookAndFeel&lt;/code&gt; field from the plan agent carries the theme, and the expert skill must extract it:&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;lookAndFeel&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;theme:Noir;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;look:dark&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;professional...'&lt;/span&gt;
&lt;span class="s"&gt;→ --theme Noir (not Fresh, not Warm, not whatever the AI thinks is "calmer")&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The common failure: the AI decides that a stress assessment needs a "warmer" expert theme and overrides Noir with Warm. Now the assessment pages are dark blue and the chat is amber. Visual whiplash.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Avatar Selection Logic
&lt;/h2&gt;

&lt;p&gt;Nine preset avatars are available, each matching a professional archetype:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;ID&lt;/th&gt;
&lt;th&gt;Style&lt;/th&gt;
&lt;th&gt;Matches&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;Middle-aged doctor, white coat&lt;/td&gt;
&lt;td&gt;Medical, health&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Middle-aged male consultant, suit&lt;/td&gt;
&lt;td&gt;Business, strategy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Young female teacher, warm&lt;/td&gt;
&lt;td&gt;Education, learning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Young male, energetic&lt;/td&gt;
&lt;td&gt;Fitness, motivation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Middle-aged male, formal&lt;/td&gt;
&lt;td&gt;Legal, compliance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Middle-aged female mentor, casual&lt;/td&gt;
&lt;td&gt;Career, coaching&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Young female manager, sharp&lt;/td&gt;
&lt;td&gt;Leadership, management&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Tech engineer, minimal&lt;/td&gt;
&lt;td&gt;Technical, IT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;Young female, bright&lt;/td&gt;
&lt;td&gt;Creative, lifestyle&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When the user doesn't provide a custom avatar URL, the AI must select from this list based on the &lt;code&gt;--role&lt;/code&gt; and &lt;code&gt;--style&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Psychological counselor → avatar 4 (young, energetic) or 6 (female mentor, casual)&lt;/li&gt;
&lt;li&gt;Legal compliance trainer → avatar 5 (formal male)&lt;/li&gt;
&lt;li&gt;Leadership coach → avatar 7 (sharp female manager)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The AI must not skip the avatar command. An expert without an avatar displays a broken image icon in the chat interface.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;--enable&lt;/code&gt; Flag
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;--enable&lt;/code&gt; flag on both &lt;code&gt;config&lt;/code&gt; and &lt;code&gt;avatar&lt;/code&gt; commands is a bare flag (no value). Its presence means &lt;code&gt;true&lt;/code&gt;. Its absence means &lt;code&gt;false&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Enabled&lt;/span&gt;
assess expert config &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Coach"&lt;/span&gt; ... &lt;span class="nt"&gt;--enable&lt;/span&gt;

&lt;span class="c"&gt;# Not enabled (config exists but chat is disabled)&lt;/span&gt;
assess expert config &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Coach"&lt;/span&gt; ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The AI would sometimes write &lt;code&gt;--enable true&lt;/code&gt; or &lt;code&gt;--enable false&lt;/code&gt;. The CLI rejects both — it's a bare flag, not a key-value pair. This was a minor but frequent syntax error.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Empty Expert Problem
&lt;/h2&gt;

&lt;p&gt;The expert module only runs for &lt;code&gt;consultation&lt;/code&gt; plan type. For &lt;code&gt;assessment&lt;/code&gt;, &lt;code&gt;exam&lt;/code&gt;, and &lt;code&gt;report&lt;/code&gt; types, there's no expert task. But sometimes the AI would add an expert task anyway:&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="c1"&gt;# Wrong — assessment doesn't include expert&lt;/span&gt;
&lt;span class="na"&gt;planType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;assessment&lt;/span&gt;
&lt;span class="na"&gt;tasks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;skill&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;form&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;skill&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;scale&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;skill&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;connect&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;skill&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;report&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;skill&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;expert&lt;/span&gt;   &lt;span class="c1"&gt;# ← not allowed for assessment&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;skill&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;share&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The decision matrix prevents this: &lt;code&gt;assessment&lt;/code&gt; maps to &lt;code&gt;form→scale→connect→report→share&lt;/code&gt; (no expert). Only &lt;code&gt;consultation&lt;/code&gt; includes expert. The plan agent's system prompt explicitly lists which modules each plan type allows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Behavior red lines are not optional.&lt;/strong&gt; Without explicit "don't do X" rules, the AI will diagnose, prescribe, and advise on things it shouldn't. Each red line must be specific and testable.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Dimension names in the welcome message build trust.&lt;/strong&gt; "I see your results in Workload and Social Support" beats "I see your results" every time.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;PATCH mode requires explicit preservation rules.&lt;/strong&gt; The AI will rewrite everything if given the chance. &lt;code&gt;[CURRENT]&lt;/code&gt; / &lt;code&gt;[PATCH]&lt;/code&gt; separation forces surgical changes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Theme consistency across modules is a lookAndFeel rule, not a style preference.&lt;/strong&gt; The expert theme must match the assessment theme. No exceptions.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Avatar selection is part of the configuration, not an afterthought.&lt;/strong&gt; An expert without an avatar is a broken feature, not a minor cosmetic gap.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Next: [The One-Click Pipeline — How one SSE stream orchestrates everything]&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>formbuilder</category>
      <category>productivity</category>
    </item>
    <item>
      <title>[6/10]Page-Templated PDF Generation: From YAML Slots to BentoUI Widgets</title>
      <dc:creator>Keo Fung | FormLM</dc:creator>
      <pubDate>Fri, 10 Jul 2026 01:07:53 +0000</pubDate>
      <link>https://dev.to/keofung/610page-templated-pdf-generation-from-yaml-slots-to-bentoui-widgets-42mh</link>
      <guid>https://dev.to/keofung/610page-templated-pdf-generation-from-yaml-slots-to-bentoui-widgets-42mh</guid>
      <description>&lt;p&gt;&lt;strong&gt;Series: Building a Modular Assessment Engine (6/10)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The report module generates personalized PDF reports. Each user who completes an assessment gets a unique report with their scores, dimension interpretations, and visualizations. This post covers the page template system, the widget grid, and the variable pipeline that makes personalization work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Template-First Approach
&lt;/h2&gt;

&lt;p&gt;Reports don't start from blank pages. They start from &lt;strong&gt;page templates&lt;/strong&gt; — predefined page structures that the engine assembles based on the assessment's dimension count and theme.&lt;/p&gt;

&lt;p&gt;The plan agent declares two values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DimN=3
theme=Noir
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The engine computes the page template list automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cover-noir          → Cover page (Noir theme)
summary-3dim        → Summary page (radar chart for 3 dimensions)
detail-dim1         → Dimension 1 detail page
detail-dim2         → Dimension 2 detail page
detail-dim3         → Dimension 3 detail page
total-assessment    → Total score conclusion page
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No AI involvement in this step. The engine reads the dimension count from the in-memory scale data and picks templates from a registry. This was a hard-won lesson — earlier versions let the AI choose template IDs, and it would invent non-existent templates or pick wrong page counts.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Runtime Fallback
&lt;/h3&gt;

&lt;p&gt;What happens when the plan agent forgets to declare dimension count? The engine recovers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In AssessAgent.skillGenerateStream()&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"report"&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;skillName&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nc"&gt;ObjectUtil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPageTemplates&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Factor&lt;/span&gt; &lt;span class="n"&gt;_factor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getFlower&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getModel&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getFactor&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_factor&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nc"&gt;ObjectUtil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_factor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getScales&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;_dimCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_factor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getScales&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_recovered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;buildPageTemplates&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_dimCount&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;planType&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setPageTemplates&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_recovered&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;FLog&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;w&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;TAG&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"pageTemplates recovered from scale data, dimCount="&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;_dimCount&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By the time the report skill runs, the scale skill has already created dimensions in memory. The engine counts them and builds the template list. The AI's declaration is a hint, not a dependency.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Widget Grid System
&lt;/h2&gt;

&lt;p&gt;Each page is a 68×48 grid (0-indexed). Widgets are placed by coordinates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;assess report widget add &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--page&lt;/span&gt; cover_1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--type&lt;/span&gt; text &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--x&lt;/span&gt; 2 &lt;span class="nt"&gt;--y&lt;/span&gt; 3 &lt;span class="nt"&gt;--w&lt;/span&gt; 44 &lt;span class="nt"&gt;--h&lt;/span&gt; 6 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--value&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;h1&amp;gt;{{DisplayName}}'s Assessment Report&amp;lt;/h1&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Constraints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;x + w ≤ 48&lt;/code&gt; (cannot exceed grid width)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;y + h ≤ 68&lt;/code&gt; (cannot exceed grid height)&lt;/li&gt;
&lt;li&gt;No overlapping widgets on the same page&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The grid system was chosen over free-form positioning because:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It's predictable — the AI can reason about layout mathematically&lt;/li&gt;
&lt;li&gt;It's responsive — the same grid produces valid layouts at different PDF sizes&lt;/li&gt;
&lt;li&gt;It prevents overlaps — a common bug with absolute positioning&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  BentoUI Style Parameters
&lt;/h3&gt;

&lt;p&gt;Widgets support a constrained set of style parameters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;assess report widget update &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--page&lt;/span&gt; detail_1 &lt;span class="nt"&gt;--id&lt;/span&gt; score_card &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--backgroundColor&lt;/span&gt; &lt;span class="s2"&gt;"#1a1a2e"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--fontColor&lt;/span&gt; &lt;span class="s2"&gt;"#E2E8F0"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--roundCorner&lt;/span&gt; 16 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enableBorder&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--borderColor&lt;/span&gt; &lt;span class="s2"&gt;"#33334c"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--borderSize&lt;/span&gt; 1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--paddingSize&lt;/span&gt; 16 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bold&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--alignX&lt;/span&gt; left
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parameter whitelist is strict. The AI can't invent &lt;code&gt;--shadow&lt;/code&gt; or &lt;code&gt;--gradient&lt;/code&gt; — they don't exist. This prevents the AI from generating CSS that the PDF renderer (iText) doesn't support.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Variable System
&lt;/h2&gt;

&lt;p&gt;Reports are personalized through template variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;assess report widget update &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--page&lt;/span&gt; summary_1 &lt;span class="nt"&gt;--id&lt;/span&gt; greeting &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--value&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;p&amp;gt;Hello, {{DisplayName}}!&amp;lt;/p&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Variables are wrapped in &lt;code&gt;{{double curly braces}}&lt;/code&gt;. The engine replaces them with actual user data at render time:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable&lt;/th&gt;
&lt;th&gt;Replaced With&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{{DisplayName}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;User's name from the form&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{{DateTime}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Report generation timestamp&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{{DimensionName}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Current dimension's name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{{DimensionScore}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;User's score on current dimension&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{{DimensionTotal}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Maximum possible score for dimension&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{{ScaleScore}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Total score across all dimensions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{{PersonalScore}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;User's personal total&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{{TotalScore}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Maximum possible total&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{{ScoreRate}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Score percentage&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The Bare Variable Bug
&lt;/h3&gt;

&lt;p&gt;The most common report bug: variables without braces.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Wrong — the engine treats "DisplayName" as literal text&lt;/span&gt;
&lt;span class="nt"&gt;--value&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;p&amp;gt;Hello, DisplayName!&amp;lt;/p&amp;gt;"&lt;/span&gt;

&lt;span class="c"&gt;# The user sees "Hello, DisplayName!" instead of "Hello, John!"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This happens because variable names look like regular English words. The AI writes &lt;code&gt;DisplayName&lt;/code&gt; when it means &lt;code&gt;{{DisplayName}}&lt;/code&gt;. The bug is invisible during generation (the command succeeds) and only surfaces when a user reads their report.&lt;/p&gt;

&lt;p&gt;The fix: a P0 validation rule that scans all &lt;code&gt;--value&lt;/code&gt; and &lt;code&gt;--logic&lt;/code&gt; parameters for bare variable names and rejects them. The SKILL.md includes a checklist of common mistakes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❌ "Hello, DisplayName" → ✅ "Hello, {{DisplayName}}"
❌ "Score: DimensionScore/DimensionTotal" → ✅ "Score: {{DimensionScore}}/{{DimensionTotal}}"
❌ "&amp;lt;p&amp;gt;DateTime&amp;lt;/p&amp;gt;" → ✅ "&amp;lt;p&amp;gt;{{DateTime}}&amp;lt;/p&amp;gt;"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Logic System: Score-Tiered Content
&lt;/h2&gt;

&lt;p&gt;Different score tiers need different interpretations. A user with severe burnout needs different text than one with mild burnout. This is handled through &lt;code&gt;--logic&lt;/code&gt; parameters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;assess report widget update &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--page&lt;/span&gt; detail_1 &lt;span class="nt"&gt;--id&lt;/span&gt; burnout_interpretation &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--type&lt;/span&gt; scale-dimension &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--scaleId&lt;/span&gt; burnout &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enableOwn&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--value&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;p&amp;gt;Your burnout level: {{DimensionScore}}/{{DimensionTotal}}&amp;lt;/p&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--logic&lt;/span&gt; &lt;span class="s2"&gt;"19-999|&amp;lt;p&amp;gt;Your burnout is low. You're managing well — keep up the practices that work.&amp;lt;/p&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--logic&lt;/span&gt; &lt;span class="s2"&gt;"11-18|&amp;lt;p&amp;gt;You show moderate signs of burnout. Consider reviewing your workload and recovery routines.&amp;lt;/p&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--logic&lt;/span&gt; &lt;span class="s2"&gt;"5-10|&amp;lt;p&amp;gt;Your burnout level is high. This signals a need for active intervention — seek support.&amp;lt;/p&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;How it works:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The engine evaluates the user's score against each &lt;code&gt;--logic&lt;/code&gt; range&lt;/li&gt;
&lt;li&gt;First match wins (so order matters)&lt;/li&gt;
&lt;li&gt;The matched content replaces the widget's display&lt;/li&gt;
&lt;li&gt;If no logic matches, &lt;code&gt;--value&lt;/code&gt; is the fallback&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The High-Score-First Rule
&lt;/h3&gt;

&lt;p&gt;Logic ranges must be added from highest to lowest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Correct order: high → mid → low&lt;/span&gt;
&lt;span class="nt"&gt;--logic&lt;/span&gt; &lt;span class="s2"&gt;"19-999|High interpretation"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nt"&gt;--logic&lt;/span&gt; &lt;span class="s2"&gt;"11-18|Mid interpretation"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nt"&gt;--logic&lt;/span&gt; &lt;span class="s2"&gt;"5-10|Low interpretation"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why? Because the engine uses &lt;strong&gt;first-match-wins&lt;/strong&gt; evaluation. If the low range is first and the user scores 20, they'd match the low range (5-10 doesn't match 20, but if ranges overlap, the first one wins). Ordering high-to-low ensures the most specific high range is checked first.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Symptom Scale Trap
&lt;/h3&gt;

&lt;p&gt;For negative-direction dimensions (symptom scales, high = bad), the logic interpretation direction must flip:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;High score → empathy + improvement suggestions&lt;/li&gt;
&lt;li&gt;Low score → affirmation + maintenance advice&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For positive-direction dimensions (ability scales, high = good):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;High score → affirmation + advanced challenges&lt;/li&gt;
&lt;li&gt;Low score → support + foundational steps&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The SKILL.md reads the &lt;code&gt;direction&lt;/code&gt; field from scale data and adjusts interpretation direction accordingly. The AI doesn't need to figure this out — the SKILL.md tells it: "if direction=negative for all dimensions, low total score = affirm good state, high total score = empathize + suggest improvement."&lt;/p&gt;

&lt;h2&gt;
  
  
  The PageTemplates Empty Report Problem
&lt;/h2&gt;

&lt;p&gt;Sometimes the pageTemplates application fails silently — the templates are declared but not applied. The report skill runs and finds an empty report (no pages exist).&lt;/p&gt;

&lt;p&gt;The SKILL.md handles this with a two-mode strategy:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Normal mode&lt;/strong&gt; (pages exist):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Only update widgets — pages are already created by templates&lt;/span&gt;
assess report widget update &lt;span class="nt"&gt;--page&lt;/span&gt; cover_1 &lt;span class="nt"&gt;--id&lt;/span&gt; title &lt;span class="nt"&gt;--value&lt;/span&gt; &lt;span class="s2"&gt;"..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Empty report mode&lt;/strong&gt; (no pages):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Step 1: Create pages manually&lt;/span&gt;
assess report page update &lt;span class="nt"&gt;--page&lt;/span&gt; cover_1 &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Cover"&lt;/span&gt; &lt;span class="nt"&gt;--backgroundColor&lt;/span&gt; &lt;span class="s2"&gt;"#1a1a2e"&lt;/span&gt;
&lt;span class="c"&gt;# (response contains "⚠️ Page was auto-created" — confirms creation)&lt;/span&gt;

&lt;span class="c"&gt;# Step 2: Add widgets (not update — they don't exist yet)&lt;/span&gt;
assess report widget add &lt;span class="nt"&gt;--page&lt;/span&gt; cover_1 &lt;span class="nt"&gt;--type&lt;/span&gt; text &lt;span class="nt"&gt;--x&lt;/span&gt; 2 &lt;span class="nt"&gt;--y&lt;/span&gt; 3 &lt;span class="nt"&gt;--w&lt;/span&gt; 44 &lt;span class="nt"&gt;--h&lt;/span&gt; 6 ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The detection is simple: if the &lt;code&gt;report-pages&lt;/code&gt; reference shows no page rows, switch to empty report mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Contrast Problem
&lt;/h2&gt;

&lt;p&gt;Dark-themed reports (Noir, Mystic, Classic) need dark page backgrounds with light text. But the AI would frequently create dark-background pages with dark text — invisible content.&lt;/p&gt;

&lt;p&gt;The rule: &lt;code&gt;fontColor&lt;/code&gt; and &lt;code&gt;backgroundColor&lt;/code&gt; must be one dark, one light. Always.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Dark background (#000~#555 or rgba alpha &amp;lt; 0.3) → fontColor: #E2E8F0, #F1F5F9, #FFFFFF
Light background (#AAA~#FFF) → fontColor: #1A202C, #2D3748, #111827
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trap: &lt;code&gt;rgba(255,255,255,0.06)&lt;/code&gt; — a semi-transparent white that looks dark visually but has "255" in the RGB values. The AI would see "white" and assign dark text. The rule now explicitly covers this: "rgba with alpha &amp;lt; 0.3 is visually dark, use light fontColor."&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqwdx7ley318x92161lef.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqwdx7ley318x92161lef.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Resource Honesty Principle
&lt;/h2&gt;

&lt;p&gt;The SKILL.md has a P0 rule about fabricated resources:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;--value&lt;/code&gt;/&lt;code&gt;--content&lt;/code&gt; HTML must NOT contain fabricated image URLs, external resources, or base64 data. Only plain text + HTML + inline CSS + CSS gradients are allowed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The AI would sometimes generate &lt;code&gt;&amp;lt;img src="https://example.com/chart.png"&amp;gt;&lt;/code&gt; — a URL that doesn't exist. The PDF renderer would try to fetch it, fail, and produce a broken image icon in the report.&lt;/p&gt;

&lt;p&gt;The only visual elements allowed are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CSS gradients (&lt;code&gt;background: linear-gradient(...)&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Inline SVG (but see the SVG rules from the connect module post)&lt;/li&gt;
&lt;li&gt;Text with styling&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No external images. No base64 blobs. If the AI can't generate it with CSS, it doesn't go in the report.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Templates are engine-computed, not AI-chosen.&lt;/strong&gt; The AI declares intent (dimension count, theme); the engine picks templates. This eliminated an entire class of bugs.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Always have a runtime fallback.&lt;/strong&gt; If templates aren't applied, recover from in-memory scale data. Don't fail silently.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Bare variables are the #1 report bug.&lt;/strong&gt; Validate every &lt;code&gt;--value&lt;/code&gt; and &lt;code&gt;--logic&lt;/code&gt; for &lt;code&gt;{{}}&lt;/code&gt; wrapping before accepting the command.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Logic order matters.&lt;/strong&gt; High-to-low, first-match-wins. Document this explicitly.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Contrast validation must handle edge cases.&lt;/strong&gt; Semi-transparent backgrounds, named colors, and hex shorthand all need explicit rules.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Next: [Expert Module — Giving AI assistants personality and boundaries]&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>assessment</category>
      <category>formbuilder</category>
    </item>
    <item>
      <title>[5/10]Generating AI-Driven Cover Pages and Visual Styles That Don't Look Generic</title>
      <dc:creator>Keo Fung | FormLM</dc:creator>
      <pubDate>Tue, 07 Jul 2026 08:47:28 +0000</pubDate>
      <link>https://dev.to/keofung/510generating-ai-driven-cover-pages-and-visual-styles-that-dont-look-generic-1lim</link>
      <guid>https://dev.to/keofung/510generating-ai-driven-cover-pages-and-visual-styles-that-dont-look-generic-1lim</guid>
      <description>&lt;p&gt;&lt;strong&gt;Series: Building a Modular Assessment Engine (5/10)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The connect module configures what users actually see: the cover page, the form-filling experience, and the final page. It also drives the visual style — colors, backgrounds, layout. This is where "functional" meets "doesn't look like a government form from 2003."&lt;/p&gt;

&lt;h2&gt;
  
  
  Three Pages, Three Jobs
&lt;/h2&gt;

&lt;p&gt;Every assessment app has three page types:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Cover Page] → [Main Page (form)] → [Final Page]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cover page&lt;/strong&gt;: The hook. Why should the user spend 5 minutes on this?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Main page&lt;/strong&gt;: The actual form. Can be one long page (form mode), one question per screen (field mode), or card-swipe style (card mode).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Final page&lt;/strong&gt;: The payoff. Download a PDF report, chat with an AI expert, or a simple thank-you.&lt;/li&gt;
&lt;/ul&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5axrtgtxol330duzhc4r.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5axrtgtxol330duzhc4r.png" alt=" " width="800" height="449"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cover Page Conversion Formula
&lt;/h2&gt;

&lt;p&gt;Most cover pages fail because they say "Welcome to our assessment." Nobody cares. The cover page needs to answer one question: &lt;strong&gt;why should I spend my time on this?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The SKILL.md enforces a four-element formula:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;assess connect cover-page add &lt;span class="nt"&gt;--id&lt;/span&gt; cover_main &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Workplace Stress Assessment"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--description&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;p&amp;gt;73% of high-pressure workers don't realize their stress exceeds healthy levels.&amp;lt;/p&amp;gt;
    &amp;lt;p&amp;gt;This assessment covers Workload, Role Stress, and Social Support. 5 minutes. Personalized report.&amp;lt;/p&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt; rich-text &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enable-title-writer&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enable-split-screen&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The four elements:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Hook sentence&lt;/strong&gt; — a counter-intuitive fact or surprising number from the knowledge base (NOT "welcome")&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Benefit statement&lt;/strong&gt; — what the user gets out of it (one sentence)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time estimate&lt;/strong&gt; — "5 minutes" (honest, not "quick")&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dimension preview&lt;/strong&gt; — 2-4 keywords showing what's covered&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The Fabricated Statistics Problem
&lt;/h3&gt;

&lt;p&gt;The hardest rule to enforce: &lt;strong&gt;statistics in the hook must come from the knowledge base, not the AI's imagination.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The AI would write things like "Studies show 87% of workers experience burnout." That number sounds credible. It's made up. On a public platform, fabricated statistics are a credibility disaster.&lt;/p&gt;

&lt;p&gt;The fix: the SKILL.md explicitly states "statistics must come from knowledge base original text, AI fabrication forbidden. The &lt;code&gt;--kbText&lt;/code&gt; contract from the form module ensures the source material is available.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Main Page Format Decision
&lt;/h2&gt;

&lt;p&gt;Three formats, each suited to different scenarios:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;UX&lt;/th&gt;
&lt;th&gt;When to Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;form&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;All questions on one page&lt;/td&gt;
&lt;td&gt;Surveys, data collection, short forms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;field&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One question per screen&lt;/td&gt;
&lt;td&gt;Assessments, exams, consultations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;card&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Card carousel within a container&lt;/td&gt;
&lt;td&gt;Medium-length assessments&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The decision is driven by the plan type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;survey → form (mandatory)
assessment/consultation/exam → field or card
learn → field
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why does survey mandate &lt;code&gt;form&lt;/code&gt;? Because surveys are often filled on desktop, and one-page-per-question for a 20-question survey feels like a dark pattern. Users want to see the full scope before committing.&lt;/p&gt;

&lt;p&gt;Why does assessment prefer &lt;code&gt;field&lt;/code&gt;? Because one-question-per-screen creates focus. The user isn't overwhelmed by seeing 15 Likert scales at once. It also enables progress indicators and per-question context.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Style System
&lt;/h2&gt;

&lt;p&gt;Visual style is applied through a separate AI call — &lt;code&gt;style apply&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;assess connect style apply &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--theme&lt;/span&gt; Noir &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--look&lt;/span&gt; &lt;span class="s2"&gt;"workplace stress assessment, psychological evaluation, dark professional, deep blue with silver accents"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--layout&lt;/span&gt; stack
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This generates CSS that's applied to all pages. The &lt;code&gt;--look&lt;/code&gt; parameter is natural language — the AI translates it into actual color values, background gradients, and typography.&lt;/p&gt;

&lt;h3&gt;
  
  
  Theme Presets
&lt;/h3&gt;

&lt;p&gt;12 preset themes map to color palettes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Light Themes&lt;/th&gt;
&lt;th&gt;Dark Themes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Minimal (light purple-gray)&lt;/td&gt;
&lt;td&gt;Earthy (deep forest green)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fresh (blue-green)&lt;/td&gt;
&lt;td&gt;Soft (deep rose)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clean (green-white)&lt;/td&gt;
&lt;td&gt;Vibrant (dark orange)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Warm (amber)&lt;/td&gt;
&lt;td&gt;Noir (deep indigo)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Airy (sky blue)&lt;/td&gt;
&lt;td&gt;Classic (deep blue)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retro (warm rose)&lt;/td&gt;
&lt;td&gt;Mystic (dark purple)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The AI picks a theme based on the assessment topic. Psychological assessments tend toward dark themes (Noir, Mystic). Health surveys lean light (Fresh, Clean). The &lt;code&gt;--look&lt;/code&gt; parameter adds nuance — "warm and supportive" modifies how the theme is applied.&lt;/p&gt;

&lt;h3&gt;
  
  
  The &lt;code&gt;apply-all&lt;/code&gt; Optimization
&lt;/h3&gt;

&lt;p&gt;Initially, &lt;code&gt;style apply&lt;/code&gt; was called once per page type (cover, main, final). Each call triggered a separate AI call to generate CSS. Three AI calls for styling alone.&lt;/p&gt;

&lt;p&gt;The fix: &lt;code&gt;style apply-all&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;assess connect style apply-all &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--theme&lt;/span&gt; Noir &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--look&lt;/span&gt; &lt;span class="s2"&gt;"dark professional, deep blue with silver accents"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--layout&lt;/span&gt; stack
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One AI call generates the style once, then applies it to all pages. This cut styling time from ~15 seconds to ~5 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The SVG Illustration System
&lt;/h2&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsxnptikcedurap390yrf.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsxnptikcedurap390yrf.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The connect module supports optional SVG illustrations for field-level context. When the user asks for "scene illustrations," each assessment question gets a small SVG above it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;assess connect main-page &lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;--field&lt;/span&gt; X4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--description&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;div&amp;gt;&amp;lt;svg viewBox='0 0 280 64' width='100%' height='64'&amp;gt;
    &amp;lt;defs&amp;gt;&amp;lt;linearGradient id='gd' x1='0' y1='0' x2='1' y2='0'&amp;gt;
      &amp;lt;stop offset='0%' stop-color='#818CF8' stop-opacity='0.3'/&amp;gt;
      &amp;lt;stop offset='100%' stop-color='#818CF8' stop-opacity='1'/&amp;gt;
    &amp;lt;/linearGradient&amp;gt;&amp;lt;/defs&amp;gt;
    &amp;lt;!-- gauge, ticks, pointer --&amp;gt;
  &amp;lt;/svg&amp;gt;&amp;lt;/div&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five standard illustration patterns cover most assessment question types:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;Visual&lt;/th&gt;
&lt;th&gt;When&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;A. Layer deconstruction&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Nested rounded boxes&lt;/td&gt;
&lt;td&gt;Concept understanding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;B. Path nodes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Bezier-connected circles&lt;/td&gt;
&lt;td&gt;Process/methodology&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;C. Dual-scene contrast&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Dashed divider, left dim/right bright&lt;/td&gt;
&lt;td&gt;Comparison/contrast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;D. Scale pointer&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Gradient track + glowing dot&lt;/td&gt;
&lt;td&gt;Frequency/degree scales&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;E. Scene narrative&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Two figures + transformation arrow&lt;/td&gt;
&lt;td&gt;Application/scenario&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  SVG Rules That Prevent Bugs
&lt;/h3&gt;

&lt;p&gt;SVGs in this system have strict rules — not for aesthetics, but because they're later rendered to PDF by Batik, and Batik is picky:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Transparent background&lt;/strong&gt; — no &lt;code&gt;&amp;lt;rect&amp;gt;&lt;/code&gt; background. Batik renders it as opaque black in PDF.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fill='none'&lt;/code&gt; or low opacity&lt;/strong&gt; — no large solid color blocks. They look terrible in print.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;viewBox&lt;/code&gt; must be explicit&lt;/strong&gt; — without it, Batik uses default dimensions and the SVG renders at wrong size in PDF.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;&amp;lt;path&amp;gt;&lt;/code&gt; &lt;code&gt;d&lt;/code&gt; attribute must start with &lt;code&gt;M&lt;/code&gt;&lt;/strong&gt; — Batik rejects paths that don't start with a move-to command.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gradient IDs must be unique per page&lt;/strong&gt; — duplicate IDs cause the first gradient to be used everywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;use&amp;gt;&lt;/code&gt;, or &lt;code&gt;&amp;lt;image&amp;gt;&lt;/code&gt;&lt;/strong&gt; — Batik doesn't execute scripts or fetch external resources.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These rules exist because each one was a bug report. The &lt;code&gt;&amp;lt;path&amp;gt;&lt;/code&gt; starting with &lt;code&gt;L&lt;/code&gt; instead of &lt;code&gt;M&lt;/code&gt;? That crashed the PDF renderer for an entire week before I found it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Final Page: Where Conversion Happens
&lt;/h2&gt;

&lt;p&gt;The final page is where the user gets their reward. Four formats:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Content&lt;/th&gt;
&lt;th&gt;When&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;report-pdf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Display + download PDF report&lt;/td&gt;
&lt;td&gt;Assessment, exam, report&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;expert&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Enter AI expert chat&lt;/td&gt;
&lt;td&gt;Consultation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rich-text&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Thank-you page&lt;/td&gt;
&lt;td&gt;Survey, learn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;url&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Redirect to external link&lt;/td&gt;
&lt;td&gt;Special cases&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The final page must answer: "I just spent 5 minutes. What did I get?"&lt;/p&gt;

&lt;p&gt;The SKILL.md enforces a three-element structure:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Congratulations&lt;/strong&gt; — acknowledge completion&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Value statement&lt;/strong&gt; — "Your personalized report shows..."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Share prompt&lt;/strong&gt; — "Found this useful? Share with a friend"&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The share prompt matters more than you'd think. It's the viral coefficient. Apps without a share prompt have ~2% share rates. Apps with a natural share prompt hit ~8%.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;--type&lt;/code&gt; vs &lt;code&gt;--page-id&lt;/code&gt; Confusion
&lt;/h2&gt;

&lt;p&gt;One more CLI gotcha. The &lt;code&gt;style set&lt;/code&gt; command can target pages by type or by ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# By type — works for cover/main/final&lt;/span&gt;
assess connect style &lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;--type&lt;/span&gt; main &lt;span class="nt"&gt;--submit-label&lt;/span&gt; &lt;span class="s2"&gt;"Submit"&lt;/span&gt;

&lt;span class="c"&gt;# By ID — needed for specific page overrides&lt;/span&gt;
assess connect style &lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;--id&lt;/span&gt; cover_main &lt;span class="nt"&gt;--writer-speed&lt;/span&gt; 80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule: &lt;code&gt;--type&lt;/code&gt; is for type-level settings (submit button text, main page config). &lt;code&gt;--id&lt;/code&gt; is for page-specific overrides (typewriter speed on a specific cover page). Using &lt;code&gt;--type cover&lt;/code&gt; when you meant &lt;code&gt;--id cover_main&lt;/code&gt; silently applies to all cover pages, which is usually not what you want.&lt;/p&gt;

&lt;p&gt;The exception: &lt;code&gt;--submit-label&lt;/code&gt; is the only parameter allowed with &lt;code&gt;--type main&lt;/code&gt;. Everything else requires &lt;code&gt;--id&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The cover page is a conversion problem, not a design problem.&lt;/strong&gt; Hook &amp;gt; benefit &amp;gt; time &amp;gt; preview. "Welcome" is not a hook.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Fabricated statistics are a credibility bomb.&lt;/strong&gt; If the number isn't in the source material, it doesn't go in the cover page. Period.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;SVG rules are PDF renderer rules.&lt;/strong&gt; What looks fine in a browser can crash Batik. Define the constraints at generation time, not at render time.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Style generation should be one AI call, not N.&lt;/strong&gt; &lt;code&gt;apply-all&lt;/code&gt; isn't just an optimization — it ensures visual consistency across pages.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The final page is where virality lives.&lt;/strong&gt; A good share prompt is worth more than a beautiful cover page.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Next: [Report Module — Page-templated PDF generation]&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>nocode</category>
      <category>formbuilder</category>
    </item>
    <item>
      <title>[4/10]Solving the Dimension Direction Problem: How Positive/Negative Scales Actually Work</title>
      <dc:creator>Keo Fung | FormLM</dc:creator>
      <pubDate>Tue, 07 Jul 2026 02:20:54 +0000</pubDate>
      <link>https://dev.to/keofung/410solving-the-dimension-direction-problem-how-positivenegative-scales-actually-work-kga</link>
      <guid>https://dev.to/keofung/410solving-the-dimension-direction-problem-how-positivenegative-scales-actually-work-kga</guid>
      <description>&lt;h1&gt;
  
  
  &lt;strong&gt;Series: Building a Modular Assessment Engine (4/10)&lt;/strong&gt;
&lt;/h1&gt;

&lt;p&gt;The scale module computes dimension scores from form answers and assigns interpretation tiers. Sounds straightforward. It's not. The "direction" problem — whether high scores mean good or bad — caused more bugs than every other module combined.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Data Structure
&lt;/h2&gt;

&lt;p&gt;Three levels of hierarchy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Factor (root)
  └── Scale (dimension)
       ├── id: "workload"
       ├── name: "Workload"
       ├── format: sum | avg | count
       ├── direction: positive | negative
       └── Data[] (score ranges)
            ├── 0-8: "Severe" → "Your workload is critically high..."
            ├── 9-14: "Moderate" → "Some signs of strain..."
            └── 15-999: "Mild" → "Your workload is manageable..."
&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiqpi8rgrzmlr1zo09421.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiqpi8rgrzmlr1zo09421.png" alt=" " width="800" height="451"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;format&lt;/strong&gt; determines how scores are computed: &lt;code&gt;sum&lt;/code&gt; (add all field scores), &lt;code&gt;avg&lt;/code&gt; (average across fields), &lt;code&gt;count&lt;/code&gt; (count specific option selections)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;direction&lt;/strong&gt; determines semantic meaning: &lt;code&gt;positive&lt;/code&gt; (high = good, like "resilience") or &lt;code&gt;negative&lt;/code&gt; (high = bad, like "burnout")&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data[]&lt;/strong&gt; are the score ranges with labels and descriptions&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Direction Problem
&lt;/h2&gt;

&lt;p&gt;Here's where it gets confusing. Consider two dimensions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Burnout (negative direction, high = bad):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Score 5 (low) → Good state, low burnout&lt;/li&gt;
&lt;li&gt;Score 25 (high) → Bad state, severe burnout&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Resilience (positive direction, high = good):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Score 5 (low) → Bad state, low resilience&lt;/li&gt;
&lt;li&gt;Score 25 (high) → Good state, high resilience&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The question: when configuring score ranges, do you write them low-to-high or high-to-low? What does "low score" mean for each direction?&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz08bzhlrahzybjwrxfue.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz08bzhlrahzybjwrxfue.png" alt=" " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt 1: Direction-Aware Configuration
&lt;/h3&gt;

&lt;p&gt;Initially, the SKILL.md told the AI to configure ranges differently based on direction:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For &lt;code&gt;positive&lt;/code&gt; dimensions: low score = worst, high score = best&lt;/li&gt;
&lt;li&gt;For &lt;code&gt;negative&lt;/code&gt; dimensions: low score = best, high score = worst&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This was logical but confusing in practice. The AI would frequently mix up which direction meant what, especially in mixed-type assessments where some dimensions are positive and others are negative.&lt;/p&gt;

&lt;p&gt;Result: roughly 30% of generated apps had at least one dimension with inverted ranges. Users with severe burnout would see "Your burnout is mild" because the ranges were flipped.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt 2: Storage-Time Reversal
&lt;/h3&gt;

&lt;p&gt;The solution that finally worked: &lt;strong&gt;unify the configuration convention&lt;/strong&gt; and let the engine handle the reversal at storage time.&lt;/p&gt;

&lt;p&gt;The rule is now dead simple for the AI:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Always configure ranges from lowest score (worst state) to highest score (best state).&lt;/strong&gt;&lt;br&gt;
The system automatically handles direction reversal internally.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Burnout (negative direction) — ranges look the same as positive&lt;/span&gt;
assess scale add &lt;span class="nt"&gt;--id&lt;/span&gt; burnout &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Burnout"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="nb"&gt;sum&lt;/span&gt; &lt;span class="nt"&gt;--direction&lt;/span&gt; negative

assess scale data add &lt;span class="nt"&gt;--scale&lt;/span&gt; burnout &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--ranges&lt;/span&gt; &lt;span class="s2"&gt;"5-10:Severe:Burnout is high, seek support,11-18:Moderate:Some signs of strain,19-999:Mild:Burnout is low, keep it up"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait — "Severe" is at the low-score end? That doesn't make sense for burnout, where low scores mean you're fine.&lt;/p&gt;

&lt;p&gt;Here's the trick: &lt;strong&gt;the system reverses the range labels when storing negative-direction dimensions.&lt;/strong&gt; So the stored data becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Stored (after reversal):
  5-10: Mild (low score = good for negative direction)
  11-18: Moderate
  19-999: Severe (high score = bad for negative direction)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The AI doesn't need to think about this. It writes ranges the same way for every dimension: low score = worst label, high score = best label. The engine flips it for negative dimensions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why This Works
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One mental model.&lt;/strong&gt; The AI always writes "low = worst, high = best." No conditional logic based on direction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Direction is metadata, not configuration logic.&lt;/strong&gt; &lt;code&gt;--direction negative&lt;/code&gt; tells the engine "this is a symptom scale" — it's used for label reversal and report interpretation direction, not for range configuration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The raw score always matches the stored ranges.&lt;/strong&gt; After reversal, a user who scores 25 on burnout (high) correctly hits "Severe" because the stored ranges have "Severe" at the high end.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The &lt;code&gt;direction&lt;/code&gt; Priority Rule
&lt;/h3&gt;

&lt;p&gt;How does the AI decide if a dimension is positive or negative? The priority is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Question wording (highest priority):&lt;/strong&gt; "I feel exhausted" (symptom, high = worse) → negative. "I can cope with stress" (ability, high = better) → positive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan declaration (secondary):&lt;/strong&gt; Only when question wording is ambiguous.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This matters because the plan agent might declare a dimension as negative, but if the actual questions measure positive constructs ("I feel capable"), the direction should be positive. The SKILL.md explicitly states: &lt;strong&gt;when knowledge declaration and question semantics conflict, question semantics win.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Ranges Continuity Bug
&lt;/h2&gt;

&lt;p&gt;Score ranges must be &lt;strong&gt;continuous and non-overlapping&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✅ Correct:   3-6, 7-8, 9-999  (7 = 6+1, 9 = 8+1)
❌ Overlap:   3-6, 3-8, 9-999  (second range starts at 3, overlapping)
❌ Gap:       3-6, 9-999        (7-8 uncovered, users in this range get nothing)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The overlap bug was common. The AI would copy the first range's &lt;code&gt;min&lt;/code&gt; as the second range's &lt;code&gt;min&lt;/code&gt; instead of incrementing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Wrong — second range starts at 3 (copied from first range's min)&lt;/span&gt;
assess scale data add &lt;span class="nt"&gt;--scale&lt;/span&gt; workload &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--ranges&lt;/span&gt; &lt;span class="s2"&gt;"3-6:Severe,3-8:Moderate,9-999:Mild"&lt;/span&gt;

&lt;span class="c"&gt;# Correct — second range starts at 7 (previous max + 1)&lt;/span&gt;
assess scale data add &lt;span class="nt"&gt;--scale&lt;/span&gt; workload &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--ranges&lt;/span&gt; &lt;span class="s2"&gt;"3-6:Severe,7-8:Moderate,9-999:Mild"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The engine now validates range continuity and rejects overlapping ranges with a clear error message.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Unreachable Range Bug
&lt;/h2&gt;

&lt;p&gt;Another classic: ranges based on theoretical scores instead of actual possible scores.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Knowledge says: "0-2 = Severe, 3-4 = Moderate, 5-6 = Mild"
But the form fields have min=1 (not 0), so the actual minimum score is 3
→ The "0-2: Severe" range is never reachable
→ Every user falls into "Moderate" or "Mild"
→ The "Severe" tier is dead code
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix: before setting ranges, the scale skill must read the actual &lt;code&gt;--min&lt;/code&gt; and &lt;code&gt;--max&lt;/code&gt; values from form fields and compute the real score range:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In the scale validation logic&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;actualMin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;actualMax&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Field&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;associatedFields&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;actualMin&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMinScore&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;actualMax&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMaxScore&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// Ranges must span [actualMin, actualMax]&lt;/span&gt;
&lt;span class="c1"&gt;// If actualMin = 3, ranges starting at 0 are unreachable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SKILL.md now has a P0 rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;ranges MUST be based on actual field scores. Read &lt;code&gt;--options&lt;/code&gt; &lt;code&gt;:score&lt;/code&gt; values from &lt;code&gt;form-fields&lt;/code&gt; reference and compute the real min/max. NEVER use theoretical scores from knowledge.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Sentinel Value
&lt;/h2&gt;

&lt;p&gt;The highest range always uses &lt;code&gt;999&lt;/code&gt; as its max:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;5-10:Severe,11-18:Moderate,19-999:Mild
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why not use the actual maximum? Because floating-point rounding and edge cases. A user who scores 18.5 (from avg format) might not match &lt;code&gt;18&lt;/code&gt; as a max boundary. &lt;code&gt;999&lt;/code&gt; as a sentinel ensures the highest tier always catches everything above its minimum.&lt;/p&gt;

&lt;p&gt;The engine treats &lt;code&gt;999&lt;/code&gt; specially — it doesn't validate against the theoretical maximum, it just means "everything above this min."&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;count&lt;/code&gt; Format Trap
&lt;/h2&gt;

&lt;p&gt;Most dimensions use &lt;code&gt;sum&lt;/code&gt; or &lt;code&gt;avg&lt;/code&gt;. But &lt;code&gt;count&lt;/code&gt; has a specific use case: counting how many times a specific option was selected.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Count how many "Yes" answers in a checklist&lt;/span&gt;
assess scale add &lt;span class="nt"&gt;--id&lt;/span&gt; compliance &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Compliance"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt; count &lt;span class="nt"&gt;--direction&lt;/span&gt; positive

&lt;span class="c"&gt;# Each field's "Yes" option has score=1, "No" has score=0&lt;/span&gt;
&lt;span class="c"&gt;# count format counts fields with score &amp;gt; 0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trap: &lt;code&gt;count&lt;/code&gt; doesn't sum scores. It counts non-zero fields. So if a field has &lt;code&gt;--score 5&lt;/code&gt;, &lt;code&gt;count&lt;/code&gt; treats it as 1 (non-zero), not 5. This is by design for binary checklists, but the AI would sometimes use &lt;code&gt;count&lt;/code&gt; for regular scored questions, producing wrong totals.&lt;/p&gt;

&lt;p&gt;The rule: &lt;code&gt;count&lt;/code&gt; is for binary (yes/no) statistics only. For regular scoring, always use &lt;code&gt;sum&lt;/code&gt;.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd0ctkgl4dd2dlmpq6azd.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd0ctkgl4dd2dlmpq6azd.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Unify the configuration convention.&lt;/strong&gt; Don't make the AI think differently for positive vs negative. Let the engine handle the reversal.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Validate against actual data, not theoretical declarations.&lt;/strong&gt; The plan agent might say "scores range 0-6" but the actual form fields produce 3-18. Always read from the source.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Range continuity is a validation problem, not an AI problem.&lt;/strong&gt; Don't trust the AI to get &lt;code&gt;min = previous_max + 1&lt;/code&gt; right every time. Validate and reject.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Direction is metadata.&lt;/strong&gt; It tells the engine how to interpret scores, not how to configure ranges. Keep it separate from the range configuration logic.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Next: [Connect Module — AI-driven cover pages and visual styles]&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>workshop</category>
      <category>formbuilder</category>
    </item>
  </channel>
</rss>
