<?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>50 Forms in 30 Days: What I Learned Building Assessment Forms With an AI Agent</title>
      <dc:creator>Keo Fung | FormLM</dc:creator>
      <pubDate>Wed, 12 Aug 2026 03:43:50 +0000</pubDate>
      <link>https://dev.to/keofung/50-forms-in-30-days-what-i-learned-building-assessment-forms-with-an-ai-agent-10io</link>
      <guid>https://dev.to/keofung/50-forms-in-30-days-what-i-learned-building-assessment-forms-with-an-ai-agent-10io</guid>
      <description>&lt;p&gt;This is the last post in the series. Over the past 30 days, I used Claude Desktop and the formlm-cli MCP server to build 50 assessment forms — everything from a 3-question mood check to a 40-item leadership 360 evaluation. Some were for production, some were tests, some were experiments to see how far I could push the AI.&lt;/p&gt;

&lt;p&gt;Here's what worked, what didn't, and what I'd do differently.&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%2F0kq5lb04sv4ci9ol2cnj.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%2F0kq5lb04sv4ci9ol2cnj.png" alt=" " width="800" height="429"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&gt;

&lt;p&gt;Over 30 days:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Forms created:&lt;/strong&gt; 50 (12 production, 38 testing/experimentation)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Total fields added:&lt;/strong&gt; ~640 (average ~13 fields per form)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCP tool calls:&lt;/strong&gt; ~3,200 (average ~64 per form)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI-induced bugs:&lt;/strong&gt; 7 (details below)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time per form:&lt;/strong&gt; 30 seconds for simple forms, 3-5 minutes for complex ones&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manual fixes needed:&lt;/strong&gt; 23 forms needed at least one manual correction after AI build&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The manual fix rate (46%) is the number I want to drive down. It means roughly half the forms the AI built weren't ready without human intervention. The bugs weren't catastrophic — but they were real.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 7 Bugs
&lt;/h2&gt;

&lt;p&gt;Here's every AI-induced bug I hit, ranked by severity:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The &lt;code&gt;field_update&lt;/code&gt; Options Wipe (Critical)
&lt;/h3&gt;

&lt;p&gt;Claude replaced an entire options list with a single option, destroying the other options' scores. This is the find-then-set pattern story from article 12. Fixed by adding &lt;code&gt;field_set_property&lt;/code&gt; and enforcing the find-first pattern.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The Staging/Production Profile Leak (High)
&lt;/h3&gt;

&lt;p&gt;Claude published a form to staging when I asked for production. This is the multi-profile story from article 18. Fixed by making the environment visible in every response — still in progress.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The &lt;code&gt;assess form clear&lt;/code&gt; Attempt (High)
&lt;/h3&gt;

&lt;p&gt;Claude tried to bulk-delete all fields in a form. Blocked by the server-side whitelist. This is the whitelist story from article 19. No fix needed — the whitelist caught it.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The Duplicate Fields (Medium)
&lt;/h3&gt;

&lt;p&gt;Claude re-added all fields after a session restart, creating duplicates. This is the idempotency story from article 15. Fixed by enforcing find-before-add.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. The Boolean Trap (Medium)
&lt;/h3&gt;

&lt;p&gt;Claude couldn't set &lt;code&gt;required: false&lt;/code&gt; — it kept omitting the parameter, which defaulted to &lt;code&gt;true&lt;/code&gt;. This is the boolean trap story from article 14. Fixed by making the schema description explicit about the default.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. The Reverse Scoring Confusion (Low)
&lt;/h3&gt;

&lt;p&gt;Claude inverted option scores when it should have used scale-level reversal. This is the scoring story from article 17. Fixed by being explicit about what kind of "reverse" I mean in prompts.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. The Forgotten App ID (Low)
&lt;/h3&gt;

&lt;p&gt;Claude lost track of the app ID mid-session and tried to add fields without it. This is from the stress test in article 13. Fix idea: maintain a "current app" state in the MCP server — not implemented yet.&lt;/p&gt;

&lt;p&gt;Four of the seven bugs were caught before reaching production. Three made it to staging. None made it to production users. The whitelist was the hardest backstop — everything else was caught by manual review.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Worked Well
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Schema-Driven Discovery
&lt;/h3&gt;

&lt;p&gt;Claude consistently called &lt;code&gt;field_schema&lt;/code&gt; and &lt;code&gt;field_config&lt;/code&gt; before adding fields. It treated the &lt;code&gt;.describe()&lt;/code&gt; strings like documentation — reading them, understanding them, and making decisions based on them. This is the single most important design decision I made: making every parameter self-documenting through zod schemas.&lt;/p&gt;

&lt;h3&gt;
  
  
  Natural Language to Field Structure
&lt;/h3&gt;

&lt;p&gt;Claude was excellent at translating assessment frameworks (PHQ-9, Maslach Burnout, Big Five) into field structures. It knew the items, the scoring ranges, and the dimensional groupings. Give it a framework name and it would produce the right fields with the right options and scores. This saved hours of manual work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Safe Defaults
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;share_publish&lt;/code&gt; tool hardcodes &lt;code&gt;formDay: 3650000&lt;/code&gt; (never expires) and &lt;code&gt;formPerm: 1&lt;/code&gt; (public read). The AI never had to decide on expiry or permissions — it just published. Safe defaults eliminated an entire class of potential mistakes.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Whitelist
&lt;/h3&gt;

&lt;p&gt;Every single time Claude tried something it shouldn't have (the &lt;code&gt;clear&lt;/code&gt; attempt, the &lt;code&gt;delete-all&lt;/code&gt; variant), the whitelist caught it. Not one dangerous command slipped through. The three-token subcommand extraction was simple, fast, and effective.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Didn't Work
&lt;/h2&gt;

&lt;h3&gt;
  
  
  State Management Across Sessions
&lt;/h3&gt;

&lt;p&gt;Claude doesn't remember what it did in a previous session. When I asked it to "continue building," it started from scratch — re-creating fields that already existed. The find-before-add pattern helped, but the fundamental issue is that MCP tools are stateless. There's no "current app" or "last field added" concept.&lt;/p&gt;

&lt;h3&gt;
  
  
  Complex Scoring Configuration
&lt;/h3&gt;

&lt;p&gt;The MCP tools handle form construction well. They don't handle scoring at all. For any form that needed dimensional scoring, reverse items, or score cutoffs, I had to configure the Scale module manually. This was the biggest gap — about 70% of the forms I built needed manual scoring configuration after the AI finished the form structure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Boolean Parameters
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;required&lt;/code&gt;, &lt;code&gt;unique&lt;/code&gt;, and &lt;code&gt;shareable&lt;/code&gt; boolean parameters were consistently problematic. Claude's instinct to omit rather than explicitly set &lt;code&gt;false&lt;/code&gt; caused silent bugs in multiple forms. The schema description fix helped, but it's a band-aid on a deeper design issue.&lt;/p&gt;

&lt;h3&gt;
  
  
  Environment Awareness
&lt;/h3&gt;

&lt;p&gt;Claude had no concept of "staging" vs "production." It used whatever profile was active and didn't know the difference. The URL in the response was the only signal, and Claude didn't flag it when the environment didn't match my request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Patterns That Emerged
&lt;/h2&gt;

&lt;p&gt;Over 30 days, I noticed consistent patterns in how Claude interacted with the tools:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude always checks before adding.&lt;/strong&gt; Before the first &lt;code&gt;field_add&lt;/code&gt;, Claude calls &lt;code&gt;field_schema&lt;/code&gt; and &lt;code&gt;field_config&lt;/code&gt;. Every single time. This is good — it means the schema descriptions are doing their job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude batches similar operations.&lt;/strong&gt; When adding 10 fields, Claude doesn't ask for confirmation between each one. It adds all 10 in sequence. This is fast, but it means if the first field is wrong, the next 9 are probably wrong too (same pattern, same mistake).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude verifies after building.&lt;/strong&gt; After adding fields, Claude calls &lt;code&gt;field_list&lt;/code&gt; to check. This is a self-verification step that catches missing fields but doesn't catch scoring errors (since &lt;code&gt;field_list&lt;/code&gt; shows structure, not scoring logic).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude gets creative when blocked.&lt;/strong&gt; When Claude couldn't find a bulk-delete tool, it tried shell commands. When it couldn't set &lt;code&gt;required: false&lt;/code&gt;, it tried omitting the parameter. The AI doesn't give up — it finds workarounds. Some of those workarounds are clever. Some are dangerous. The whitelist is what keeps the dangerous ones in check.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;If I were starting over, I'd make these changes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Add a &lt;code&gt;field_add_many&lt;/code&gt; batch tool.&lt;/strong&gt; Reduce 10 round-trips to 1. But define clear error semantics — if one field in the batch fails, return the error for that field and let the AI decide whether to retry.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Make the "current app" implicit.&lt;/strong&gt; After &lt;code&gt;app_create&lt;/code&gt; or &lt;code&gt;app_get&lt;/code&gt;, set the app ID as the active context. Subsequent field operations default to the active app. This eliminates the "forgotten app ID" bug.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Expose scoring configuration via MCP.&lt;/strong&gt; This is the biggest gap. Without it, the AI can only build the form shell — not the assessment engine. I'm working on this, but it requires careful whitelist design (AI can set scoring, but not reset it).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Add environment labels to every response.&lt;/strong&gt; "Published on staging.formlm.me" not just the URL. Make the environment impossible to miss.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Make boolean parameters required, not optional.&lt;/strong&gt; If &lt;code&gt;required&lt;/code&gt; must be &lt;code&gt;true&lt;/code&gt; or &lt;code&gt;false&lt;/code&gt;, never &lt;code&gt;undefined&lt;/code&gt;, Claude can't fall into the "omit means default" trap. The extra parameter in every call is worth the safety.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Meta-Lesson
&lt;/h2&gt;

&lt;p&gt;The biggest thing I learned over 30 days is this: &lt;strong&gt;designing tools for AI agents is different from designing tools for humans.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When I design a CLI for humans, I optimize for ergonomics — sensible defaults, helpful flags, clear error messages. Humans read the docs, understand the context, and make informed decisions.&lt;/p&gt;

&lt;p&gt;When I design an MCP server for AI agents, I optimize for explicitness — no hidden defaults, no ambiguous parameters, no operations that require context the AI doesn't have. Every tool needs to be self-contained: the description tells the AI what it does, the schema tells the AI what to pass, and the response tells the AI what happened.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;.describe()&lt;/code&gt; string on every zod field isn't just documentation. It's the instruction manual the AI reads before deciding to use the tool. A vague description ("Mark as required") leads to vague behavior. An explicit description ("Defaults to true if omitted. Set to false explicitly for optional fields.") leads to correct behavior.&lt;/p&gt;

&lt;p&gt;This series has been about the journey from "the code works" to "the AI can use the code correctly." The first part — writing working code — took a few weeks. The second part — making it AI-safe — has taken 30 days and counting, and I'm still finding edge cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;The formlm-cli MCP server now has 6 layered tools + 6 knowledge resources, a server-side whitelist, explicit schema descriptions, and the patterns (find-then-set, find-before-add) that make AI interactions safe. The CLI is open source. The platform is live at formlm.me.&lt;/p&gt;

&lt;p&gt;We're not there yet. But 50 forms in 30 days has proven that the foundation works. The tools are solid. The patterns are reliable. The whitelist holds. The AI can build forms.&lt;/p&gt;

&lt;p&gt;Now it can build assessments too.&lt;/p&gt;




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

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>productivity</category>
      <category>mcp</category>
    </item>
    <item>
      <title>The Whitelist That Saved My Production Data: A Security Boundary Story</title>
      <dc:creator>Keo Fung | FormLM</dc:creator>
      <pubDate>Wed, 12 Aug 2026 03:42:09 +0000</pubDate>
      <link>https://dev.to/keofung/the-whitelist-that-saved-my-production-data-a-security-boundary-story-1nl7</link>
      <guid>https://dev.to/keofung/the-whitelist-that-saved-my-production-data-a-security-boundary-story-1nl7</guid>
      <description>&lt;p&gt;I almost lost every field in a production form last week. Not to a bug, not to a crash — to an AI that was trying to be helpful. If I hadn't built a server-side command whitelist, I'd be writing a very different post right now.&lt;/p&gt;

&lt;p&gt;Here's what happened, how the whitelist caught it, and why I'll never ship an AI-accessible API without one.&lt;/p&gt;

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

&lt;p&gt;I was cleaning up a staging form that had accumulated junk fields from various test runs. I told Claude:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"This form has too many test fields. Remove all the fields that start with 'test' and clean it up."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude looked at the field list, saw about 15 fields starting with "test_", and decided the most efficient approach was not to call &lt;code&gt;field_remove&lt;/code&gt; 15 times. Instead, it tried something... creative.&lt;/p&gt;

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

&lt;p&gt;Claude called &lt;code&gt;field_remove&lt;/code&gt; for individual test fields — that part was fine. But after the fifth call, it apparently decided individual removals were too slow. It tried to find a "remove all" or "clear" command.&lt;/p&gt;

&lt;p&gt;The MCP tools don't expose a "clear all fields" operation. But Claude was looking at the CLI command structure, and it noticed that the underlying server commands follow a pattern: &lt;code&gt;assess form &amp;lt;subcommand&amp;gt;&lt;/code&gt;. If &lt;code&gt;assess form remove&lt;/code&gt; removes one field, maybe &lt;code&gt;assess form clear&lt;/code&gt; removes all of them?&lt;/p&gt;

&lt;p&gt;Claude didn't have a &lt;code&gt;field_clear&lt;/code&gt; MCP tool. But it could construct the raw CLI command and... wait, it couldn't. The MCP server only exposes tools, not raw command execution. Every MCP tool call goes through &lt;code&gt;execCommand(cmd)&lt;/code&gt;, where &lt;code&gt;cmd&lt;/code&gt; is a string built from the tool parameters.&lt;/p&gt;

&lt;p&gt;But here's the thing — the CLI itself is installed on my machine. Claude Desktop could, in theory, run shell commands. And it did. Claude opened a terminal and tried:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;formlm-cli field clear &lt;span class="nt"&gt;--app&lt;/span&gt; abc123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI doesn't have a &lt;code&gt;field clear&lt;/code&gt; command. It failed with "unknown command." But Claude wasn't done. It tried:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;formlm-cli assess form clear &lt;span class="nt"&gt;--app&lt;/span&gt; abc123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one sent a request to the server. The server received &lt;code&gt;assess form clear --app abc123&lt;/code&gt;. And that's where the whitelist kicked in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Whitelist
&lt;/h2&gt;

&lt;p&gt;On the server side, every command sent through &lt;code&gt;/api/v1/mcp/exec&lt;/code&gt; goes through a whitelist check. The whitelist operates at the subcommand level — not the module level, not the individual field level, but the specific subcommand.&lt;/p&gt;

&lt;p&gt;The server extracts the first three space-separated tokens from the command string and checks them against an allowlist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;assess app list      ✅ allowed
assess app create    ✅ allowed
assess app update    ✅ allowed
assess app remove    ✅ allowed
assess form add      ✅ allowed
assess form update   ✅ allowed
assess form remove   ✅ allowed
assess form move     ✅ allowed
assess form find     ✅ allowed
assess form query    ✅ allowed
assess form types    ✅ allowed
assess form config   ✅ allowed
assess form set-property  ✅ allowed
assess share set     ✅ allowed
assess share query   ✅ allowed
assess share url     ✅ allowed
...
assess form clear    ❌ BLOCKED
assess app clear     ❌ BLOCKED
assess form delete-all  ❌ BLOCKED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;assess form clear&lt;/code&gt; was not on the allowlist. The server returned:&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;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Command not allowed: assess form clear"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&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;Claude got the error. It tried once more with a slight variation (&lt;code&gt;assess form clear-all&lt;/code&gt;), got the same 403, and then gave up and went back to removing fields one by one. The whitelist held.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Subcommand-Level, Not Module-Level
&lt;/h2&gt;

&lt;p&gt;I could have built the whitelist at the module level — allow everything under &lt;code&gt;assess form *&lt;/code&gt; and block everything else. But that would have been too permissive. The &lt;code&gt;assess form&lt;/code&gt; module has both safe operations (add, update, find, query) and dangerous ones (clear, delete-all, reset). Module-level whitelisting would either allow everything (dangerous) or block everything (useless).&lt;/p&gt;

&lt;p&gt;Subcommand-level whitelisting is more precise:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;assess form add&lt;/code&gt; — safe, creates data&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;assess form remove&lt;/code&gt; — safe in isolation, removes one field&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;assess form clear&lt;/code&gt; — DANGEROUS, removes ALL fields&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;assess form move&lt;/code&gt; — safe, reorders&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;assess form update&lt;/code&gt; — safe, modifies one field&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The dangerous operations are the "bulk" ones — &lt;code&gt;clear&lt;/code&gt;, &lt;code&gt;delete-all&lt;/code&gt;, &lt;code&gt;reset&lt;/code&gt;. These are administrative commands that a human would run intentionally but an AI might trigger accidentally (or "creatively," as in my case).&lt;/p&gt;

&lt;h2&gt;
  
  
  What Would Have Happened Without the Whitelist
&lt;/h2&gt;

&lt;p&gt;If &lt;code&gt;assess form clear&lt;/code&gt; had been allowed, the server would have:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Deleted every field in the form — not just the "test_" fields, ALL of them&lt;/li&gt;
&lt;li&gt;Returned a success response&lt;/li&gt;
&lt;li&gt;Left me with a published form that had zero fields&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Respondents clicking the form URL would see an empty page. No error, no warning — just an empty form. The data wouldn't be recoverable because &lt;code&gt;clear&lt;/code&gt; doesn't soft-delete; it hard-deletes.&lt;/p&gt;

&lt;p&gt;I had about 200 responses on that form from real users. The responses would have been orphaned — response data without corresponding field definitions. The reporting module would have crashed trying to map response data to non-existent fields.&lt;/p&gt;

&lt;p&gt;It would have been a very bad day.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Extract Function
&lt;/h2&gt;

&lt;p&gt;The key piece of the whitelist is the subcommand extraction. It takes the raw command string and extracts the first three tokens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract_sub_command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&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;cmd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then it checks against the allowlist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;ALLOWED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;assess app list&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;assess app create&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;assess app update&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;assess app remove&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;assess form add&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;assess form update&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;assess form remove&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;assess form move&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;assess form find&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;assess form query&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;assess form types&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;assess form config&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;assess form set-property&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;assess share set&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;assess share query&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;assess share url&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;is_allowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;sub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_sub_command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;sub&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ALLOWED&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simple. Three tokens. If the combination isn't in the set, it's rejected. No regex, no pattern matching, no partial matching. Just a set lookup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Works for AI Agents
&lt;/h2&gt;

&lt;p&gt;The whitelist works because it's &lt;strong&gt;server-side&lt;/strong&gt;. The AI can't bypass it. Even if Claude somehow figures out a way to send raw HTTP requests (which it shouldn't be able to through the MCP server), the server still checks the command against the whitelist.&lt;/p&gt;

&lt;p&gt;The whitelist works because it's &lt;strong&gt;explicit&lt;/strong&gt;. There's no "allow everything except..." logic. It's a pure allowlist — if it's not on the list, it's not allowed. This means I can't accidentally forget to block a dangerous command; I can only forget to &lt;em&gt;allow&lt;/em&gt; a safe one (which is a much safer failure mode).&lt;/p&gt;

&lt;p&gt;The whitelist works because it's &lt;strong&gt;at the right granularity&lt;/strong&gt;. Blocking at the module level is too coarse. Blocking at the parameter level (e.g., "don't allow &lt;code&gt;--app&lt;/code&gt; with certain IDs") is too fine. The subcommand level — &lt;code&gt;assess form clear&lt;/code&gt; vs &lt;code&gt;assess form remove&lt;/code&gt; — is the sweet spot. It's where the semantic difference between "delete one" and "delete all" lives.&lt;/p&gt;

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

&lt;p&gt;After this incident, I made three changes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Added more blocked commands to the documentation.&lt;/strong&gt; The MCP tool descriptions now explicitly say "There is no bulk delete operation available." Claude doesn't need to go looking for one.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Added logging.&lt;/strong&gt; Every blocked command is now logged with the timestamp, the command string, and the user token (to identify which AI agent tried it). I can see if Claude (or any other MCP client) attempts a blocked command.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Added a "dangerous operations" review.&lt;/strong&gt; Once a month, I review the full command list and ask: "Is there any subcommand that an AI might reasonably try that I haven't explicitly allowed or blocked?" It's a manual process, but it catches edge cases.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The whitelist is the most boring piece of security infrastructure in the whole system. It's a set lookup. It's not fancy. But it's the one thing that stood between my production data and an AI that was trying to be efficient.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The formlm-cli MCP server is backed by a server-side command whitelist that blocks bulk operations. 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>llm</category>
    </item>
    <item>
      <title>When Your AI Deploys to the Wrong Server: Multi-Profile Management Gone Wrong</title>
      <dc:creator>Keo Fung | FormLM</dc:creator>
      <pubDate>Tue, 11 Aug 2026 07:32:14 +0000</pubDate>
      <link>https://dev.to/keofung/when-your-ai-deploys-to-the-wrong-server-multi-profile-management-gone-wrong-5g3d</link>
      <guid>https://dev.to/keofung/when-your-ai-deploys-to-the-wrong-server-multi-profile-management-gone-wrong-5g3d</guid>
      <description>&lt;p&gt;I have two FormLM environments: staging (&lt;code&gt;staging.formlm.me&lt;/code&gt;) and production (&lt;code&gt;formlm.me&lt;/code&gt;). I use staging for testing new forms, new field configurations, and AI-driven builds. Production is where real respondents fill out real assessments.&lt;/p&gt;

&lt;p&gt;The formlm-cli supports multiple profiles, so I have both configured:&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;"profiles"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"staging"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://staging.formlm.me"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"staging-token-here"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"active"&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;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://formlm.me"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"prod-token-here"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;active&lt;/code&gt; flag determines which profile the CLI uses by default. I keep &lt;code&gt;staging&lt;/code&gt; active because that's where I do most of my AI-driven builds.&lt;/p&gt;

&lt;p&gt;This setup works fine — until it doesn't.&lt;/p&gt;

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

&lt;p&gt;I was testing a new employee engagement survey. I'd been building it on staging all morning, iterating with Claude on the field structure and scoring. After about 20 iterations, the form was ready. I told Claude:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"This form is ready. Publish it and give me the production URL so I can share it with the team."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude called &lt;code&gt;share_publish&lt;/code&gt; and &lt;code&gt;share_url&lt;/code&gt;. The URL came back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://staging.formlm.me/s/abc123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the staging URL. Not production.&lt;/p&gt;

&lt;p&gt;Claude had published to staging because &lt;code&gt;staging&lt;/code&gt; was the active profile. It didn't switch to production — partly because I didn't tell it to, and partly because the MCP tools don't have a "switch profile" operation.&lt;/p&gt;

&lt;p&gt;I hadn't noticed until I sent the link to my team and someone said "is this the staging environment?"&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the MCP Tools Don't Switch Profiles
&lt;/h2&gt;

&lt;p&gt;Looking at the config code, profile management is handled at the CLI level, not the MCP level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getBaseUrl&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FORMLM_BASE_URL&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;getProfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;runtimeProfile&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;getActiveProfile&lt;/span&gt;&lt;span class="p"&gt;()?.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://formlm.me&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;profileName&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FORMLM_TOKEN&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;getProfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;profileName&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;runtimeProfile&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;getActiveProfile&lt;/span&gt;&lt;span class="p"&gt;()?.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's a priority chain:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;FORMLM_BASE_URL&lt;/code&gt; / &lt;code&gt;FORMLM_TOKEN&lt;/code&gt; environment variables (highest)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;runtimeProfile&lt;/code&gt; (set by &lt;code&gt;--profile&lt;/code&gt; global CLI option)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;activeProfile&lt;/code&gt; (the profile flagged as active in config.json)&lt;/li&gt;
&lt;li&gt;Hardcoded defaults (lowest)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The MCP server uses &lt;code&gt;getBaseUrl()&lt;/code&gt; and &lt;code&gt;getToken()&lt;/code&gt; without any profile parameter. So it always uses the active profile — or the environment variables if they're set.&lt;/p&gt;

&lt;p&gt;There's no MCP tool to switch profiles. No &lt;code&gt;config_switch_profile&lt;/code&gt;, no &lt;code&gt;config_set_active&lt;/code&gt;. The AI can't change which environment it's targeting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Token Priority Trap
&lt;/h2&gt;

&lt;p&gt;Here's where it gets more insidious. The priority chain means:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;If &lt;code&gt;FORMLM_TOKEN&lt;/code&gt; is set as an environment variable, it overrides &lt;em&gt;everything&lt;/em&gt; — regardless of which profile is active.&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;FORMLM_TOKEN&lt;/code&gt; is not set, the runtime profile is checked next.&lt;/li&gt;
&lt;li&gt;If neither is set, the active profile is used.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I had &lt;code&gt;FORMLM_TOKEN&lt;/code&gt; set in my shell environment from a previous session. It was a production token. But my active profile was &lt;code&gt;staging&lt;/code&gt;. So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;getBaseUrl()&lt;/code&gt; returned &lt;code&gt;staging.formlm.me&lt;/code&gt; (from the active profile)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;getToken()&lt;/code&gt; returned the production token (from the environment variable)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I was sending a production token to the staging server. The staging server rejected it. The error message said "Unauthorized." Claude saw the error and tried &lt;code&gt;auth_login&lt;/code&gt; with my staging credentials — which worked, but created a new token that overwrote the production token in my config.&lt;/p&gt;

&lt;p&gt;Now my staging profile had a production token, my production profile still had the old (valid) production token, and my environment variable was still set to the production token. Three places storing tokens, two of them stale, one of them wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Config File Permissions
&lt;/h2&gt;

&lt;p&gt;One thing I got right: the config file is stored with &lt;code&gt;0o600&lt;/code&gt; permissions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;saveConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CONFIG_DIR&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CONFIG_DIR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;recursive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CONFIG_FILE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mo"&gt;0o600&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;~/.formlm/config.json&lt;/code&gt; is readable only by the owner. That's good — tokens are sensitive. But it also means if something goes wrong with the config, I can't easily inspect it from a different user context (like a CI/CD pipeline).&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Fixed It
&lt;/h2&gt;

&lt;p&gt;The fix was a three-step manual process:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Unset &lt;code&gt;FORMLM_TOKEN&lt;/code&gt; from my shell environment&lt;/li&gt;
&lt;li&gt;Manually edit &lt;code&gt;~/.formlm/config.json&lt;/code&gt; to restore the correct staging token&lt;/li&gt;
&lt;li&gt;Switch the active profile to production before publishing&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The third step is the one I want to automate. I need an MCP tool that lets the AI switch profiles:&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;config_set_active&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;Switch the active profile&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;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;Profile name&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;const&lt;/span&gt; &lt;span class="nx"&gt;found&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setActiveProfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;found&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`✅ Active profile: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`❌ Profile not found: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But I haven't added it yet. The concern is security — should an AI be able to switch to a production environment? In my case, I trust Claude to do what I ask. But if someone else uses this CLI with their own Claude instance, they might not want the AI to be able to switch to production and start creating forms there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Safer Approach: Per-Command Profile
&lt;/h2&gt;

&lt;p&gt;The CLI already supports a &lt;code&gt;--profile&lt;/code&gt; flag that sets the runtime profile for a single command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;formlm-cli &lt;span class="nt"&gt;--profile&lt;/span&gt; production share publish &lt;span class="nt"&gt;--app&lt;/span&gt; abc123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This sets &lt;code&gt;runtimeProfile&lt;/code&gt; for the duration of the command, without changing the active profile in the config. It's the safest approach — you explicitly specify which environment for each operation.&lt;/p&gt;

&lt;p&gt;But the MCP server doesn't expose this. Every MCP tool call goes through &lt;code&gt;execCommand(cmd)&lt;/code&gt; without a profile parameter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;profileName&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ExecResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;baseUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getBaseUrl&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;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;profileName&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;The &lt;code&gt;profileName&lt;/code&gt; parameter exists but is never passed by the MCP tools. So every MCP tool call uses the active profile.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Should Do
&lt;/h2&gt;

&lt;p&gt;Here's my plan:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Add &lt;code&gt;config_list_profiles&lt;/code&gt;&lt;/strong&gt; — let the AI see what profiles exist (names and URLs, but not tokens)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add &lt;code&gt;config_set_active&lt;/code&gt;&lt;/strong&gt; — let the AI switch profiles, but only between non-production profiles by default&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add an &lt;code&gt;appId&lt;/code&gt;-scoped profile&lt;/strong&gt; — store the profile used to create each app, and warn the AI when it tries to operate on an app from a different profile&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The third one is the most interesting. If I create an app on staging, the app ID should remember it belongs to staging. When Claude tries to publish that app ID from a production profile, the tool should warn: "This app was created on staging. Are you sure you want to publish it on production?"&lt;/p&gt;

&lt;p&gt;That's a cross-cutting concern — it requires the server to track which environment created each app, not just the CLI. But it would have prevented this entire incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Lesson: Environment Leaking Is Silent
&lt;/h2&gt;

&lt;p&gt;The worst part about this incident wasn't the error — it was the &lt;em&gt;silence&lt;/em&gt;. When I sent a production token to staging, the staging server rejected it. That's loud — you get an error immediately. But when the active profile was staging and I asked Claude to "publish to production," Claude published to staging &lt;em&gt;without any error&lt;/em&gt;. The operation succeeded. The URL was valid. It just pointed to the wrong server.&lt;/p&gt;

&lt;p&gt;Environment leaking is silent when the operation succeeds on the wrong environment. There's no error to catch. The only way to notice is to read the URL — and if you're trusting the AI to handle the details, you might not check.&lt;/p&gt;

&lt;p&gt;The fix isn't just adding tools to switch profiles. It's making the environment visible in every response. When Claude calls &lt;code&gt;share_publish&lt;/code&gt;, the response should include the environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✅ Published on staging.formlm.me. URL: https://staging.formlm.me/s/abc123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not just the URL. The environment name. So you can't accidentally share a staging link thinking it's production.&lt;/p&gt;

&lt;p&gt;I haven't implemented this yet. But after this incident, it's the next thing on my list.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The formlm-cli supports multi-profile management with per-profile tokens and URLs. 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>tools</category>
    </item>
    <item>
      <title>Teaching an AI to Score: Reverse Items, Direction, and the Gap Between Knowing and Doing</title>
      <dc:creator>Keo Fung | FormLM</dc:creator>
      <pubDate>Thu, 06 Aug 2026 06:47:18 +0000</pubDate>
      <link>https://dev.to/keofung/teaching-an-ai-to-score-reverse-items-direction-and-the-gap-between-knowing-and-doing-3in9</link>
      <guid>https://dev.to/keofung/teaching-an-ai-to-score-reverse-items-direction-and-the-gap-between-knowing-and-doing-3in9</guid>
      <description>&lt;p&gt;Claude knows what a reverse-scored item is. If you ask it "explain reverse scoring in psychometrics," it'll give you a textbook definition — how negatively worded items are scored in the opposite direction to prevent response bias, how a 5 on "I am often unhappy" should be scored as a 1 on a wellbeing scale.&lt;/p&gt;

&lt;p&gt;But knowing the concept and applying it correctly when building a form are different skills. Here's what happened when I asked Claude to build a PHQ-9 depression screening form with proper scoring.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PHQ-9 Challenge
&lt;/h2&gt;

&lt;p&gt;The PHQ-9 is a 9-item depression screening tool. Each item is scored 0-3 (Not at all = 0, Several days = 1, More than half the days = 2, Nearly every day = 3). Total score ranges 0-27. Higher = more depressive symptoms.&lt;/p&gt;

&lt;p&gt;Simple enough. No reverse items (unlike many wellbeing scales). The challenge was getting Claude to set up the scoring correctly on each field.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Claude Got Right
&lt;/h2&gt;

&lt;p&gt;Claude nailed the field structure. Nine radio fields, each with four options, each option scored 0-3:&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; q1 &lt;span class="nt"&gt;--type&lt;/span&gt; radio &lt;span class="nt"&gt;--title&lt;/span&gt; &lt;span class="s2"&gt;"Little interest or pleasure in doing things"&lt;/span&gt; &lt;span class="nt"&gt;--options&lt;/span&gt; &lt;span class="s2"&gt;"Not at all:0,Several days:1,More than half:2,Nearly every day:3"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It used &lt;code&gt;label:score&lt;/code&gt; format correctly. The scoring was embedded in the options string. When a respondent selects "Several days," the system records a score of 1. Claude understood the format from the schema description:&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, comma-separated, supports label:score&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 description — "supports label:score" — was enough. Claude figured out the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Claude Got Wrong
&lt;/h2&gt;

&lt;p&gt;The problem wasn't the scoring. It was the total. After building the form, I asked Claude: "What's the total score range?"&lt;/p&gt;

&lt;p&gt;Claude said: "0 to 27, with higher scores indicating more severe depressive symptoms."&lt;/p&gt;

&lt;p&gt;Correct. But the form Claude built couldn't compute that total. At the time, the MCP tools could set per-option scores, but couldn't configure the scale-level scoring — the part that says "sum all 9 items and compare to cutoffs (5=mild, 10=moderate, 15=moderately severe, 20=severe)."&lt;/p&gt;

&lt;p&gt;That configuration lives in the Scale module, which the MCP server didn't expose at the time. So Claude built a form that collects the right data, but it couldn't tell the respondent "your score is 14, which indicates moderate depression."&lt;/p&gt;

&lt;p&gt;This was the gap I kept hitting: the AI could build the data collection layer, but not the scoring layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Reverse Item Problem
&lt;/h2&gt;

&lt;p&gt;To test Claude's understanding of reverse scoring, I then asked it to build a WHO-5 Wellbeing Index. The WHO-5 has 5 items, all positively worded, scored 0-5. But here's the catch: higher scores mean &lt;em&gt;better&lt;/em&gt; wellbeing. If you want to use it as a depression screen, you need to invert the scores (100 - raw score × 4 = percentage, with &amp;lt;50 indicating likely depression).&lt;/p&gt;

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

&lt;blockquote&gt;
&lt;p&gt;"Build a WHO-5 Wellbeing Index. 5 items, 6-point scale (0=At no time to 5=All of the time). This is a positive-direction scale — higher scores mean better wellbeing."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude built the form correctly:&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; w1 &lt;span class="nt"&gt;--type&lt;/span&gt; radio &lt;span class="nt"&gt;--title&lt;/span&gt; &lt;span class="s2"&gt;"I have felt cheerful and in good spirits"&lt;/span&gt; &lt;span class="nt"&gt;--options&lt;/span&gt; &lt;span class="s2"&gt;"At no time:0,Some of the time:1,Less than half:2,About half:3,More than half:4,All of the time:5"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All 5 items, correct scoring. Then I asked: "If someone scores all 5s, what's their wellbeing percentage?"&lt;/p&gt;

&lt;p&gt;Claude said: "100%. Raw score 25, multiplied by 4 = 100."&lt;/p&gt;

&lt;p&gt;Correct. Then I asked the real question: "How would you set up a reverse-scored version of this scale?"&lt;/p&gt;

&lt;p&gt;Claude's answer: "I would negate the scores for reverse-scored items. For item 'I felt cheerful,' the options would be scored 5=At no time, 0=All of the time, reversing the direction."&lt;/p&gt;

&lt;p&gt;Wait. Claude reversed the scores on the &lt;em&gt;options&lt;/em&gt; — swapping 0 and 5 on every option. That's not reverse scoring. Reverse scoring means: for this specific item, invert the score &lt;em&gt;after&lt;/em&gt; the respondent answers. The option labels stay the same; the score mapping flips.&lt;/p&gt;

&lt;p&gt;Claude's approach would work &lt;em&gt;if&lt;/em&gt; the item was negatively worded. But the WHO-5 items are all positively worded — you don't reverse-score a positively worded item. Claude conflated "reverse the option scores" with "reverse scoring as a psychometric technique."&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the Confusion Comes From
&lt;/h2&gt;

&lt;p&gt;The confusion is understandable. "Reverse scoring" is an ambiguous term:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Textual reversal&lt;/strong&gt; — rewording an item negatively ("I do NOT feel cheerful")&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Score reversal&lt;/strong&gt; — inverting the numeric score for specific items (item score = max + min - raw score)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scale direction&lt;/strong&gt; — whether higher total = better (positive direction) or higher total = worse (negative direction)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Claude mixed up all three. It thought "reverse scoring" meant swapping the option scores (approach 2), which would only make sense if the item was negatively worded (approach 1). And it didn't consider the scale's overall direction (approach 3).&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Explicit Instructions
&lt;/h2&gt;

&lt;p&gt;I found that the key was being very explicit about what kind of "reverse" I meant. Instead of saying "add a reverse-scored item," I now say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Add item q9: 'I have thoughts that I would be better off dead.' This item is in the negative direction — higher scores indicate worse outcomes. Score the options 0=Not at all, 3=Nearly every day, same as other items. The reverse-scoring will be handled at the scale level, not the item level."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When I phrase it this way — explicitly saying "same as other items" and "handled at the scale level" — Claude stops trying to invert option scores and just builds the field normally.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;field_set_property&lt;/code&gt; Workaround
&lt;/h2&gt;

&lt;p&gt;Since the MCP tools couldn't configure scale-level scoring at the time, I've started using &lt;code&gt;field_set_property&lt;/code&gt; as a workaround for option-level score fixes.&lt;/p&gt;

&lt;p&gt;If Claude builds a radio field with wrong scores, I can fix individual option scores without rebuilding the field:&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;# Fix the score on option index 2&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.2.score &lt;span class="nt"&gt;--value&lt;/span&gt; 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This updates exactly one option's score. It's not scale-level scoring, but it lets me correct per-option scoring mistakes that Claude makes when it misunderstands the scoring direction.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Lesson: Assessment Theory Is Hard
&lt;/h2&gt;

&lt;p&gt;The deeper lesson here isn't about the MCP tools or the schema descriptions. It's that assessment theory is genuinely complicated, and LLMs have a surface-level understanding of it that breaks down on edge cases.&lt;/p&gt;

&lt;p&gt;Claude knows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What a Likert scale is ✅&lt;/li&gt;
&lt;li&gt;What reverse scoring means (conceptually) ✅&lt;/li&gt;
&lt;li&gt;How to format &lt;code&gt;label:score&lt;/code&gt; options ✅&lt;/li&gt;
&lt;li&gt;When to use radio vs scale fields ✅&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Claude doesn't reliably know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which items should be reverse-scored vs negatively worded ❌&lt;/li&gt;
&lt;li&gt;The difference between item-level reversal and scale-level reversal ❌&lt;/li&gt;
&lt;li&gt;When higher scores should mean better vs worse (scale direction) ❌&lt;/li&gt;
&lt;li&gt;How to configure dimensional scoring (not exposed via MCP) ❌&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This isn't a Claude-specific limitation. It's the gap between knowing a concept and applying it in a specific tool context. The same gap exists for any domain — the AI knows what a database index is, but it might create one on the wrong column.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm Doing About It
&lt;/h2&gt;

&lt;p&gt;I'm adding more context to the MCP tool descriptions. For &lt;code&gt;field_add&lt;/code&gt;, the &lt;code&gt;options&lt;/code&gt; parameter now reads:&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, comma-separated, supports label:score. For scored scales, use label:score format. Higher scores should align with the construct direction — if the scale measures depression, higher scores = more depression.&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;And for scoring-related fields:&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;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;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;Question score weight. Use for items that should contribute more/less to the total. Default is 1 (equal weight).&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;These descriptions won't make Claude a psychometrics expert. But they nudge it in the right direction — explicitly stating that "higher scores should align with the construct direction" prevents the most common mistake (inverting scores when the scale is already in the right direction).&lt;/p&gt;

&lt;p&gt;The rest — dimensional grouping, cutoff scores, report templates — will come when I expose the Scale module through MCP. That's the next big piece of work. Until then, the AI can build the form, and I handle the scoring by hand.&lt;/p&gt;

&lt;p&gt;It's still faster than building the whole thing manually. And now, it &lt;em&gt;is&lt;/em&gt; the "AI does everything" dream.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The formlm-cli MCP server supports per-option scoring with label:score format. 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>workshop</category>
      <category>assessment</category>
    </item>
    <item>
      <title>From a Sentence to a Live Form: The Complete Timeline</title>
      <dc:creator>Keo Fung | FormLM</dc:creator>
      <pubDate>Thu, 30 Jul 2026 09:46:12 +0000</pubDate>
      <link>https://dev.to/keofung/from-a-sentence-to-a-live-form-the-complete-timeline-28j6</link>
      <guid>https://dev.to/keofung/from-a-sentence-to-a-live-form-the-complete-timeline-28j6</guid>
      <description>&lt;p&gt;People keep asking me: "How long does it actually take?" Like, from the moment you type a sentence to the moment you have a live, shareable URL people can fill in. Not the marketing answer ("90 seconds!") — the real answer, including every tool call, every round-trip, every second of waiting.&lt;/p&gt;

&lt;p&gt;So I timed it. Here's the complete timeline of building a personality assessment form with Claude Desktop and the formlm-cli MCP server, from first keystroke to live URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Prompt (0:00)
&lt;/h2&gt;

&lt;p&gt;I typed this into Claude Desktop:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Build a Big Five personality assessment. 10 items, 5-point Likert scale (1=Strongly Disagree to 5=Strongly Agree). Two items per trait: openness, conscientiousness, extraversion, agreeableness, neuroticism. Mix some reverse-scored items in. Name it 'Big Five Personality Quick Check.' Then publish it and give me the URL."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One sentence. 43 words. No field IDs, no type names, no API parameters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool Call 1: app_create (0:03)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Claude → MCP: app_create
  name: "Big Five Personality Quick Check"
  description: (omitted)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;MCP → Server: POST /api/v1/mcp/exec
  cmd: "assess app create --name 'Big Five Personality Quick Check' --json"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;Server&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;MCP:&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;code:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;data:&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;appId:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"a8f3k2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;name:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Big Five Personality Quick Check"&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;Round-trip time: ~800ms. The app was created.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Elapsed: 0:04&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool Call 2: field_schema (0:04)
&lt;/h2&gt;

&lt;p&gt;Before adding any fields, Claude checked what field types were available.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Claude → MCP: field_schema
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Server response: input, textarea, radio, scale, date, ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a pattern I've seen consistently — Claude always checks the schema before adding fields. It's reading the available types like a developer reading API docs.&lt;/p&gt;

&lt;p&gt;Round-trip time: ~600ms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Elapsed: 0:05&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool Call 3: field_config (0:05)
&lt;/h2&gt;

&lt;p&gt;Claude wanted to know what properties a &lt;code&gt;scale&lt;/code&gt; field accepts.&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;Claude → MCP&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;field_config&lt;/span&gt;
  &lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scale"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;Server&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;min&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;max&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;required&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="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="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;It found &lt;code&gt;min&lt;/code&gt; and &lt;code&gt;max&lt;/code&gt; — exactly what it needed for a 5-point Likert scale.&lt;/p&gt;

&lt;p&gt;Round-trip time: ~500ms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Elapsed: 0:06&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool Calls 4-13: field_add × 10 (0:06 - 0:22)
&lt;/h2&gt;

&lt;p&gt;Claude added 10 scale fields, one for each item. Here's the first one:&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;Claude → MCP&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;field_add&lt;/span&gt;
  &lt;span class="s"&gt;appId&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a8f3k2"&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;o1"&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scale"&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;have&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;vivid&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;imagination"&lt;/span&gt;
  &lt;span class="na"&gt;min&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;Server&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;code:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;data:&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;fieldId:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"o1"&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="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;Each &lt;code&gt;field_add&lt;/code&gt; call took about 1.5 seconds (including Claude's thinking time between calls). 10 fields × 1.5s = 15 seconds.&lt;/p&gt;

&lt;p&gt;Here's the full field list Claude generated:&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;Trait&lt;/th&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;o1&lt;/td&gt;
&lt;td&gt;Openness&lt;/td&gt;
&lt;td&gt;I have a vivid imagination&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;o2&lt;/td&gt;
&lt;td&gt;Openness&lt;/td&gt;
&lt;td&gt;I prefer variety over routine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;c1&lt;/td&gt;
&lt;td&gt;Conscientiousness&lt;/td&gt;
&lt;td&gt;I am organized and pay attention to details&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;c2&lt;/td&gt;
&lt;td&gt;Conscientiousness&lt;/td&gt;
&lt;td&gt;I tend to leave things until the last minute (reverse)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;e1&lt;/td&gt;
&lt;td&gt;Extraversion&lt;/td&gt;
&lt;td&gt;I feel energized around other people&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;e2&lt;/td&gt;
&lt;td&gt;Extraversion&lt;/td&gt;
&lt;td&gt;I prefer quiet, solitary activities (reverse)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a1&lt;/td&gt;
&lt;td&gt;Agreeableness&lt;/td&gt;
&lt;td&gt;I trust people easily&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a2&lt;/td&gt;
&lt;td&gt;Agreeableness&lt;/td&gt;
&lt;td&gt;I can be cold and uncaring (reverse)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;n1&lt;/td&gt;
&lt;td&gt;Neuroticism&lt;/td&gt;
&lt;td&gt;I worry about things more than others&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;n2&lt;/td&gt;
&lt;td&gt;Neuroticism&lt;/td&gt;
&lt;td&gt;I stay calm under pressure (reverse)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Claude correctly identified which items should be reverse-scored and marked them with "(reverse)" in the title. It didn't have a &lt;code&gt;--reverse&lt;/code&gt; flag to set (the MCP tools don't expose reverse-scoring configuration yet), so it used textual cues.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Elapsed: 0:22&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool Call 14: field_list (0:22)
&lt;/h2&gt;

&lt;p&gt;Before publishing, Claude called &lt;code&gt;field_list&lt;/code&gt; to verify all 10 fields were there.&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;Claude → MCP&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;field_list&lt;/span&gt;
  &lt;span class="s"&gt;appId&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a8f3k2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;Server&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="err"&gt;o&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;o&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;c&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;c&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;e&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;e&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;a&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;a&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;n&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;n&lt;/span&gt;&lt;span class="mi"&gt;2&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;10 fields confirmed. This is a verification step — Claude checking its own work before moving to the publish phase.&lt;/p&gt;

&lt;p&gt;Round-trip time: ~700ms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Elapsed: 0:23&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool Call 15: share_publish (0:23)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;Claude → MCP&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;share_publish&lt;/span&gt;
  &lt;span class="s"&gt;appId&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a8f3k2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;Server&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;code:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;data:&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;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;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 form was live. The &lt;code&gt;formDay: 3650000&lt;/code&gt; is the hardcoded "never expires" default — the AI doesn't need to decide on an expiry date.&lt;/p&gt;

&lt;p&gt;Round-trip time: ~900ms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Elapsed: 0:24&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool Call 16: share_url (0:24)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;Claude → MCP&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;share_url&lt;/span&gt;
  &lt;span class="s"&gt;appId&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a8f3k2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Server response: "https://formlm.me/s/a8f3k2"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The MCP server constructs the full URL by combining the server's base URL with the relative path returned by the API. Claude received the URL and displayed it to me.&lt;/p&gt;

&lt;p&gt;Round-trip time: ~500ms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Elapsed: 0:25&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Total
&lt;/h2&gt;

&lt;p&gt;From first keystroke to live URL: &lt;strong&gt;25 seconds&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;16 tool calls. 6 round-trips to the server for API operations (app create, schema, config, field add ×10 batched mentally, field list, publish, URL). Total server processing time: ~7 seconds. Total Claude thinking time: ~18 seconds.&lt;/p&gt;

&lt;p&gt;The form was live, accessible, and accepting responses. 10 fields, 5-point Likert scale, all 10 items present and correctly ordered.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Missing
&lt;/h2&gt;

&lt;p&gt;The form worked, but it wasn't a complete assessment. Here's what was missing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No scoring.&lt;/strong&gt; The form collected answers but didn't compute trait scores. Each trait has 2 items, and 4 of them are reverse-scored — but the scoring engine needs that information configured in the Scale module, which the MCP tools didn't expose at the time. So the form was a data collection tool, not an assessment engine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No dimension labels.&lt;/strong&gt; The fields have IDs like &lt;code&gt;o1&lt;/code&gt;, &lt;code&gt;o2&lt;/code&gt;, &lt;code&gt;c1&lt;/code&gt;, &lt;code&gt;c2&lt;/code&gt; — meaningful to me, but not to the system. The dimension mapping (o1+o2 = openness score) needs to be configured separately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No report template.&lt;/strong&gt; When someone completes the assessment, they get a "thank you" page, not a personality profile. The report generation is a separate module.&lt;/p&gt;

&lt;p&gt;So 25 seconds gets you a live form. Getting a live &lt;em&gt;assessment&lt;/em&gt; — with scoring, dimensions, and a generated report — took another 5-10 minutes of manual configuration in the web UI. Still fast, but not the "one sentence to full assessment" dream.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Tool Call Breakdown
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;Tool Calls&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Round-trips&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;App creation&lt;/td&gt;
&lt;td&gt;1 (app_create)&lt;/td&gt;
&lt;td&gt;4s&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema discovery&lt;/td&gt;
&lt;td&gt;2 (field_schema, field_config)&lt;/td&gt;
&lt;td&gt;2s&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Field creation&lt;/td&gt;
&lt;td&gt;10 (field_add ×10)&lt;/td&gt;
&lt;td&gt;16s&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verification&lt;/td&gt;
&lt;td&gt;1 (field_list)&lt;/td&gt;
&lt;td&gt;1s&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Publishing&lt;/td&gt;
&lt;td&gt;2 (share_publish, share_url)&lt;/td&gt;
&lt;td&gt;2s&lt;/td&gt;
&lt;td&gt;2&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;16&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;25s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;16 round-trips in 25 seconds. That's roughly 1.5 seconds per round-trip, which includes network latency, server processing, and Claude's thinking time between calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Slowed It Down
&lt;/h2&gt;

&lt;p&gt;Nothing, really. This was a clean run — no errors, no retries, no duplicate fields. The 25 seconds was almost entirely Claude thinking and the network round-tripping.&lt;/p&gt;

&lt;p&gt;The slowest part was the 10 &lt;code&gt;field_add&lt;/code&gt; calls. Each one is a separate API call. If I added a batch &lt;code&gt;field_add_many&lt;/code&gt; tool that accepted an array of fields in one call, I could cut the round-trips from 16 to 7, and the total time from 25 seconds to maybe 12 seconds.&lt;/p&gt;

&lt;p&gt;But batching has a downside: if one field in the batch fails, do you fail the whole batch or skip the failed one? I haven't figured out the right error semantics yet, so I'm keeping individual field_add for now.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Honest Answer
&lt;/h2&gt;

&lt;p&gt;"How long does it take?" — 25 seconds for a 10-field form, from sentence to live URL. That's the honest answer.&lt;/p&gt;

&lt;p&gt;But 25 seconds gets you a form. A real assessment — with scoring, reverse items, dimensions, and a report — used to take another 5-10 minutes of manual work. &lt;/p&gt;

&lt;p&gt;That's the gap I was working on next.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The formlm-cli MCP server can build and publish forms in under 30 seconds. 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>startup</category>
    </item>
    <item>
      <title>When Your AI Deploys to the Wrong Server: Multi-Profile Management Gone Wrong</title>
      <dc:creator>Keo Fung | FormLM</dc:creator>
      <pubDate>Wed, 29 Jul 2026 07:06:38 +0000</pubDate>
      <link>https://dev.to/keofung/when-your-ai-deploys-to-the-wrong-server-multi-profile-management-gone-wrong-3anl</link>
      <guid>https://dev.to/keofung/when-your-ai-deploys-to-the-wrong-server-multi-profile-management-gone-wrong-3anl</guid>
      <description>&lt;p&gt;I have two FormLM environments: staging (&lt;code&gt;staging.formlm.me&lt;/code&gt;) and production (&lt;code&gt;formlm.me&lt;/code&gt;). I use staging for testing new forms, new field configurations, and AI-driven builds. Production is where real respondents fill out real assessments.&lt;/p&gt;

&lt;p&gt;The formlm-cli supports multiple profiles, so I have both configured:&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;"profiles"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"staging"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://staging.formlm.me"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"staging-token-here"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"active"&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;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://formlm.me"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"prod-token-here"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;active&lt;/code&gt; flag determines which profile the CLI uses by default. I keep &lt;code&gt;staging&lt;/code&gt; active because that's where I do most of my AI-driven builds.&lt;/p&gt;

&lt;p&gt;This setup works fine — until it doesn't.&lt;/p&gt;

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

&lt;p&gt;I was testing a new employee engagement survey. I'd been building it on staging all morning, iterating with Claude on the field structure and scoring. After about 20 iterations, the form was ready. I told Claude:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"This form is ready. Publish it and give me the production URL so I can share it with the team."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude called &lt;code&gt;share_publish&lt;/code&gt; and &lt;code&gt;share_url&lt;/code&gt;. The URL came back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://staging.formlm.me/s/abc123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the staging URL. Not production.&lt;/p&gt;

&lt;p&gt;Claude had published to staging because &lt;code&gt;staging&lt;/code&gt; was the active profile. It didn't switch to production — partly because I didn't tell it to, and partly because the MCP tools don't have a "switch profile" operation.&lt;/p&gt;

&lt;p&gt;I hadn't noticed until I sent the link to my team and someone said "is this the staging environment?"&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the MCP Tools Don't Switch Profiles
&lt;/h2&gt;

&lt;p&gt;Looking at the config code, profile management is handled at the CLI level, not the MCP level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getBaseUrl&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FORMLM_BASE_URL&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;getProfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;runtimeProfile&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;getActiveProfile&lt;/span&gt;&lt;span class="p"&gt;()?.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://formlm.me&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;profileName&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FORMLM_TOKEN&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;getProfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;profileName&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;runtimeProfile&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;getActiveProfile&lt;/span&gt;&lt;span class="p"&gt;()?.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's a priority chain:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;FORMLM_BASE_URL&lt;/code&gt; / &lt;code&gt;FORMLM_TOKEN&lt;/code&gt; environment variables (highest)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;runtimeProfile&lt;/code&gt; (set by &lt;code&gt;--profile&lt;/code&gt; global CLI option)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;activeProfile&lt;/code&gt; (the profile flagged as active in config.json)&lt;/li&gt;
&lt;li&gt;Hardcoded defaults (lowest)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The MCP server uses &lt;code&gt;getBaseUrl()&lt;/code&gt; and &lt;code&gt;getToken()&lt;/code&gt; without any profile parameter. So it always uses the active profile — or the environment variables if they're set.&lt;/p&gt;

&lt;p&gt;There's no MCP tool to switch profiles. No &lt;code&gt;config_switch_profile&lt;/code&gt;, no &lt;code&gt;config_set_active&lt;/code&gt;. The AI can't change which environment it's targeting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Token Priority Trap
&lt;/h2&gt;

&lt;p&gt;Here's where it gets more insidious. The priority chain means:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;If &lt;code&gt;FORMLM_TOKEN&lt;/code&gt; is set as an environment variable, it overrides &lt;em&gt;everything&lt;/em&gt; — regardless of which profile is active.&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;FORMLM_TOKEN&lt;/code&gt; is not set, the runtime profile is checked next.&lt;/li&gt;
&lt;li&gt;If neither is set, the active profile is used.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I had &lt;code&gt;FORMLM_TOKEN&lt;/code&gt; set in my shell environment from a previous session. It was a production token. But my active profile was &lt;code&gt;staging&lt;/code&gt;. So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;getBaseUrl()&lt;/code&gt; returned &lt;code&gt;staging.formlm.me&lt;/code&gt; (from the active profile)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;getToken()&lt;/code&gt; returned the production token (from the environment variable)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I was sending a production token to the staging server. The staging server rejected it. The error message said "Unauthorized." Claude saw the error and tried &lt;code&gt;auth_login&lt;/code&gt; with my staging credentials — which worked, but created a new token that overwrote the production token in my config.&lt;/p&gt;

&lt;p&gt;Now my staging profile had a production token, my production profile still had the old (valid) production token, and my environment variable was still set to the production token. Three places storing tokens, two of them stale, one of them wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Config File Permissions
&lt;/h2&gt;

&lt;p&gt;One thing I got right: the config file is stored with &lt;code&gt;0o600&lt;/code&gt; permissions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;saveConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CONFIG_DIR&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CONFIG_DIR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;recursive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CONFIG_FILE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mo"&gt;0o600&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;~/.formlm/config.json&lt;/code&gt; is readable only by the owner. That's good — tokens are sensitive. But it also means if something goes wrong with the config, I can't easily inspect it from a different user context (like a CI/CD pipeline).&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Fixed It
&lt;/h2&gt;

&lt;p&gt;The fix was a three-step manual process:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Unset &lt;code&gt;FORMLM_TOKEN&lt;/code&gt; from my shell environment&lt;/li&gt;
&lt;li&gt;Manually edit &lt;code&gt;~/.formlm/config.json&lt;/code&gt; to restore the correct staging token&lt;/li&gt;
&lt;li&gt;Switch the active profile to production before publishing&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The third step is the one I want to automate. I need an MCP tool that lets the AI switch profiles:&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;config_set_active&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;Switch the active profile&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;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;Profile name&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;const&lt;/span&gt; &lt;span class="nx"&gt;found&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setActiveProfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;found&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`✅ Active profile: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`❌ Profile not found: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But I haven't added it yet. The concern is security — should an AI be able to switch to a production environment? In my case, I trust Claude to do what I ask. But if someone else uses this CLI with their own Claude instance, they might not want the AI to be able to switch to production and start creating forms there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Safer Approach: Per-Command Profile
&lt;/h2&gt;

&lt;p&gt;The CLI already supports a &lt;code&gt;--profile&lt;/code&gt; flag that sets the runtime profile for a single command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;formlm-cli &lt;span class="nt"&gt;--profile&lt;/span&gt; production share publish &lt;span class="nt"&gt;--app&lt;/span&gt; abc123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This sets &lt;code&gt;runtimeProfile&lt;/code&gt; for the duration of the command, without changing the active profile in the config. It's the safest approach — you explicitly specify which environment for each operation.&lt;/p&gt;

&lt;p&gt;But the MCP server doesn't expose this. Every MCP tool call goes through &lt;code&gt;execCommand(cmd)&lt;/code&gt; without a profile parameter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;profileName&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ExecResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;baseUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getBaseUrl&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;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;profileName&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;The &lt;code&gt;profileName&lt;/code&gt; parameter exists but is never passed by the MCP tools. So every MCP tool call uses the active profile.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Should Do
&lt;/h2&gt;

&lt;p&gt;Here's my plan:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Add &lt;code&gt;config_list_profiles&lt;/code&gt;&lt;/strong&gt; — let the AI see what profiles exist (names and URLs, but not tokens)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add &lt;code&gt;config_set_active&lt;/code&gt;&lt;/strong&gt; — let the AI switch profiles, but only between non-production profiles by default&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add an &lt;code&gt;appId&lt;/code&gt;-scoped profile&lt;/strong&gt; — store the profile used to create each app, and warn the AI when it tries to operate on an app from a different profile&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The third one is the most interesting. If I create an app on staging, the app ID should remember it belongs to staging. When Claude tries to publish that app ID from a production profile, the tool should warn: "This app was created on staging. Are you sure you want to publish it on production?"&lt;/p&gt;

&lt;p&gt;That's a cross-cutting concern — it requires the server to track which environment created each app, not just the CLI. But it would have prevented this entire incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Lesson: Environment Leaking Is Silent
&lt;/h2&gt;

&lt;p&gt;The worst part about this incident wasn't the error — it was the &lt;em&gt;silence&lt;/em&gt;. When I sent a production token to staging, the staging server rejected it. That's loud — you get an error immediately. But when the active profile was staging and I asked Claude to "publish to production," Claude published to staging &lt;em&gt;without any error&lt;/em&gt;. The operation succeeded. The URL was valid. It just pointed to the wrong server.&lt;/p&gt;

&lt;p&gt;Environment leaking is silent when the operation succeeds on the wrong environment. There's no error to catch. The only way to notice is to read the URL — and if you're trusting the AI to handle the details, you might not check.&lt;/p&gt;

&lt;p&gt;The fix isn't just adding tools to switch profiles. It's making the environment visible in every response. When Claude calls &lt;code&gt;share_publish&lt;/code&gt;, the response should include the environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✅ Published on staging.formlm.me. URL: https://formlm.me/s/abc123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not just the URL. The environment name. So you can't accidentally share a staging link thinking it's production.&lt;/p&gt;

&lt;p&gt;I haven't implemented this yet. But after this incident, it's the next thing on my list.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The formlm-cli supports multi-profile management with per-profile tokens and URLs. 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>backend</category>
      <category>saas</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>Teaching an AI to Score: Reverse Items, Direction, and the Gap Between Knowing and Doing</title>
      <dc:creator>Keo Fung | FormLM</dc:creator>
      <pubDate>Mon, 27 Jul 2026 02:34:03 +0000</pubDate>
      <link>https://dev.to/keofung/teaching-an-ai-to-score-reverse-items-direction-and-the-gap-between-knowing-and-doing-d7a</link>
      <guid>https://dev.to/keofung/teaching-an-ai-to-score-reverse-items-direction-and-the-gap-between-knowing-and-doing-d7a</guid>
      <description>&lt;p&gt;Claude knows what a reverse-scored item is. If you ask it "explain reverse scoring in psychometrics," it'll give you a textbook definition — how negatively worded items are scored in the opposite direction to prevent response bias, how a 5 on "I am often unhappy" should be scored as a 1 on a wellbeing scale.&lt;/p&gt;

&lt;p&gt;But knowing the concept and applying it correctly when building a form are different skills. Here's what happened when I asked Claude to build a PHQ-9 depression screening form with proper scoring.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PHQ-9 Challenge
&lt;/h2&gt;

&lt;p&gt;The PHQ-9 is a 9-item depression screening tool. Each item is scored 0-3 (Not at all = 0, Several days = 1, More than half the days = 2, Nearly every day = 3). Total score ranges 0-27. Higher = more depressive symptoms.&lt;/p&gt;

&lt;p&gt;Simple enough. No reverse items (unlike many wellbeing scales). The challenge was getting Claude to set up the scoring correctly on each field.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Claude Got Right
&lt;/h2&gt;

&lt;p&gt;Claude nailed the field structure. Nine radio fields, each with four options, each option scored 0-3:&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; q1 &lt;span class="nt"&gt;--type&lt;/span&gt; radio &lt;span class="nt"&gt;--title&lt;/span&gt; &lt;span class="s2"&gt;"Little interest or pleasure in doing things"&lt;/span&gt; &lt;span class="nt"&gt;--options&lt;/span&gt; &lt;span class="s2"&gt;"Not at all:0,Several days:1,More than half:2,Nearly every day:3"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It used &lt;code&gt;label:score&lt;/code&gt; format correctly. The scoring was embedded in the options string. When a respondent selects "Several days," the system records a score of 1. Claude understood the format from the schema description:&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, comma-separated, supports label:score&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 description — "supports label:score" — was enough. Claude figured out the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Claude Got Wrong
&lt;/h2&gt;

&lt;p&gt;The problem wasn't the scoring. It was the total. After building the form, I asked Claude: "What's the total score range?"&lt;/p&gt;

&lt;p&gt;Claude said: "0 to 27, with higher scores indicating more severe depressive symptoms."&lt;/p&gt;

&lt;p&gt;Correct. But the form Claude built couldn't compute that total. At the time, the MCP tools could set per-option scores, but couldn't configure the scale-level scoring — the part that says "sum all 9 items and compare to cutoffs (5=mild, 10=moderate, 15=moderately severe, 20=severe)."&lt;/p&gt;

&lt;p&gt;That configuration lives in the Scale module, which the MCP server didn't expose at the time. So Claude built a form that collects the right data, but it couldn't tell the respondent "your score is 14, which indicates moderate depression."&lt;/p&gt;

&lt;p&gt;This was the gap I kept hitting: the AI could build the data collection layer, but not the scoring layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Reverse Item Problem
&lt;/h2&gt;

&lt;p&gt;To test Claude's understanding of reverse scoring, I then asked it to build a WHO-5 Wellbeing Index. The WHO-5 has 5 items, all positively worded, scored 0-5. But here's the catch: higher scores mean &lt;em&gt;better&lt;/em&gt; wellbeing. If you want to use it as a depression screen, you need to invert the scores (100 - raw score × 4 = percentage, with &amp;lt;50 indicating likely depression).&lt;/p&gt;

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

&lt;blockquote&gt;
&lt;p&gt;"Build a WHO-5 Wellbeing Index. 5 items, 6-point scale (0=At no time to 5=All of the time). This is a positive-direction scale — higher scores mean better wellbeing."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude built the form correctly:&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; w1 &lt;span class="nt"&gt;--type&lt;/span&gt; radio &lt;span class="nt"&gt;--title&lt;/span&gt; &lt;span class="s2"&gt;"I have felt cheerful and in good spirits"&lt;/span&gt; &lt;span class="nt"&gt;--options&lt;/span&gt; &lt;span class="s2"&gt;"At no time:0,Some of the time:1,Less than half:2,About half:3,More than half:4,All of the time:5"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All 5 items, correct scoring. Then I asked: "If someone scores all 5s, what's their wellbeing percentage?"&lt;/p&gt;

&lt;p&gt;Claude said: "100%. Raw score 25, multiplied by 4 = 100."&lt;/p&gt;

&lt;p&gt;Correct. Then I asked the real question: "How would you set up a reverse-scored version of this scale?"&lt;/p&gt;

&lt;p&gt;Claude's answer: "I would negate the scores for reverse-scored items. For item 'I felt cheerful,' the options would be scored 5=At no time, 0=All of the time, reversing the direction."&lt;/p&gt;

&lt;p&gt;Wait. Claude reversed the scores on the &lt;em&gt;options&lt;/em&gt; — swapping 0 and 5 on every option. That's not reverse scoring. Reverse scoring means: for this specific item, invert the score &lt;em&gt;after&lt;/em&gt; the respondent answers. The option labels stay the same; the score mapping flips.&lt;/p&gt;

&lt;p&gt;Claude's approach would work &lt;em&gt;if&lt;/em&gt; the item was negatively worded. But the WHO-5 items are all positively worded — you don't reverse-score a positively worded item. Claude conflated "reverse the option scores" with "reverse scoring as a psychometric technique."&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the Confusion Comes From
&lt;/h2&gt;

&lt;p&gt;The confusion is understandable. "Reverse scoring" is an ambiguous term:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Textual reversal&lt;/strong&gt; — rewording an item negatively ("I do NOT feel cheerful")&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Score reversal&lt;/strong&gt; — inverting the numeric score for specific items (item score = max + min - raw score)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scale direction&lt;/strong&gt; — whether higher total = better (positive direction) or higher total = worse (negative direction)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Claude mixed up all three. It thought "reverse scoring" meant swapping the option scores (approach 2), which would only make sense if the item was negatively worded (approach 1). And it didn't consider the scale's overall direction (approach 3).&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Explicit Instructions
&lt;/h2&gt;

&lt;p&gt;I found that the key was being very explicit about what kind of "reverse" I meant. Instead of saying "add a reverse-scored item," I now say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Add item q9: 'I have thoughts that I would be better off dead.' This item is in the negative direction — higher scores indicate worse outcomes. Score the options 0=Not at all, 3=Nearly every day, same as other items. The reverse-scoring will be handled at the scale level, not the item level."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When I phrase it this way — explicitly saying "same as other items" and "handled at the scale level" — Claude stops trying to invert option scores and just builds the field normally.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;field_set_property&lt;/code&gt; Workaround
&lt;/h2&gt;

&lt;p&gt;Since the MCP tools couldn't configure scale-level scoring at the time, I've started using &lt;code&gt;field_set_property&lt;/code&gt; as a workaround for option-level score fixes.&lt;/p&gt;

&lt;p&gt;If Claude builds a radio field with wrong scores, I can fix individual option scores without rebuilding the field:&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;# Fix the score on option index 2&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.2.score &lt;span class="nt"&gt;--value&lt;/span&gt; 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This updates exactly one option's score. It's not scale-level scoring, but it lets me correct per-option scoring mistakes that Claude makes when it misunderstands the scoring direction.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Lesson: Assessment Theory Is Hard
&lt;/h2&gt;

&lt;p&gt;The deeper lesson here isn't about the MCP tools or the schema descriptions. It's that assessment theory is genuinely complicated, and LLMs have a surface-level understanding of it that breaks down on edge cases.&lt;/p&gt;

&lt;p&gt;Claude knows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What a Likert scale is ✅&lt;/li&gt;
&lt;li&gt;What reverse scoring means (conceptually) ✅&lt;/li&gt;
&lt;li&gt;How to format &lt;code&gt;label:score&lt;/code&gt; options ✅&lt;/li&gt;
&lt;li&gt;When to use radio vs scale fields ✅&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Claude doesn't reliably know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which items should be reverse-scored vs negatively worded ❌&lt;/li&gt;
&lt;li&gt;The difference between item-level reversal and scale-level reversal ❌&lt;/li&gt;
&lt;li&gt;When higher scores should mean better vs worse (scale direction) ❌&lt;/li&gt;
&lt;li&gt;How to configure dimensional scoring (not exposed via MCP) ❌&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This isn't a Claude-specific limitation. It's the gap between knowing a concept and applying it in a specific tool context. The same gap exists for any domain — the AI knows what a database index is, but it might create one on the wrong column.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm Doing About It
&lt;/h2&gt;

&lt;p&gt;I'm adding more context to the MCP tool descriptions. For &lt;code&gt;field_add&lt;/code&gt;, the &lt;code&gt;options&lt;/code&gt; parameter now reads:&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, comma-separated, supports label:score. For scored scales, use label:score format. Higher scores should align with the construct direction — if the scale measures depression, higher scores = more depression.&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;And for scoring-related fields:&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;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;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;Question score weight. Use for items that should contribute more/less to the total. Default is 1 (equal weight).&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;These descriptions won't make Claude a psychometrics expert. But they nudge it in the right direction — explicitly stating that "higher scores should align with the construct direction" prevents the most common mistake (inverting scores when the scale is already in the right direction).&lt;/p&gt;

&lt;p&gt;The rest — dimensional grouping, cutoff scores, report templates — will come when I expose the Scale module through MCP. That's the next big piece of work. Until then, the AI can build the form, and I handle the scoring by hand.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The formlm-cli MCP server supports per-option scoring with label:score format. 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>java</category>
      <category>mcp</category>
    </item>
    <item>
      <title>From a Sentence to a Live Form: The Complete Timeline</title>
      <dc:creator>Keo Fung | FormLM</dc:creator>
      <pubDate>Mon, 27 Jul 2026 02:33:01 +0000</pubDate>
      <link>https://dev.to/keofung/from-a-sentence-to-a-live-form-the-complete-timeline-281m</link>
      <guid>https://dev.to/keofung/from-a-sentence-to-a-live-form-the-complete-timeline-281m</guid>
      <description>&lt;p&gt;People keep asking me: "How long does it actually take?" Like, from the moment you type a sentence to the moment you have a live, shareable URL people can fill in. Not the marketing answer ("90 seconds!") — the real answer, including every tool call, every round-trip, every second of waiting.&lt;/p&gt;

&lt;p&gt;So I timed it. Here's the complete timeline of building a personality assessment form with Claude Desktop and the formlm-cli MCP server, from first keystroke to live URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Prompt (0:00)
&lt;/h2&gt;

&lt;p&gt;I typed this into Claude Desktop:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Build a Big Five personality assessment. 10 items, 5-point Likert scale (1=Strongly Disagree to 5=Strongly Agree). Two items per trait: openness, conscientiousness, extraversion, agreeableness, neuroticism. Mix some reverse-scored items in. Name it 'Big Five Personality Quick Check.' Then publish it and give me the URL."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One sentence. 43 words. No field IDs, no type names, no API parameters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool Call 1: app_create (0:03)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Claude → MCP: app_create
  name: "Big Five Personality Quick Check"
  description: (omitted)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;MCP → Server: POST /api/v1/mcp/exec
  cmd: "assess app create --name 'Big Five Personality Quick Check' --json"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;Server&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;MCP:&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;code:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;data:&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;appId:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"a8f3k2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;name:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Big Five Personality Quick Check"&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;Round-trip time: ~800ms. The app was created.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Elapsed: 0:04&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool Call 2: field_schema (0:04)
&lt;/h2&gt;

&lt;p&gt;Before adding any fields, Claude checked what field types were available.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Claude → MCP: field_schema
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Server response: input, textarea, radio, scale, date, ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a pattern I've seen consistently — Claude always checks the schema before adding fields. It's reading the available types like a developer reading API docs.&lt;/p&gt;

&lt;p&gt;Round-trip time: ~600ms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Elapsed: 0:05&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool Call 3: field_config (0:05)
&lt;/h2&gt;

&lt;p&gt;Claude wanted to know what properties a &lt;code&gt;scale&lt;/code&gt; field accepts.&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;Claude → MCP&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;field_config&lt;/span&gt;
  &lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scale"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;Server&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;min&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;max&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;required&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="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="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;It found &lt;code&gt;min&lt;/code&gt; and &lt;code&gt;max&lt;/code&gt; — exactly what it needed for a 5-point Likert scale.&lt;/p&gt;

&lt;p&gt;Round-trip time: ~500ms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Elapsed: 0:06&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool Calls 4-13: field_add × 10 (0:06 - 0:22)
&lt;/h2&gt;

&lt;p&gt;Claude added 10 scale fields, one for each item. Here's the first one:&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;Claude → MCP&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;field_add&lt;/span&gt;
  &lt;span class="s"&gt;appId&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a8f3k2"&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;o1"&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scale"&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;have&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;vivid&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;imagination"&lt;/span&gt;
  &lt;span class="na"&gt;min&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;Server&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;code:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;data:&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;fieldId:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"o1"&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="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;Each &lt;code&gt;field_add&lt;/code&gt; call took about 1.5 seconds (including Claude's thinking time between calls). 10 fields × 1.5s = 15 seconds.&lt;/p&gt;

&lt;p&gt;Here's the full field list Claude generated:&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;Trait&lt;/th&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;o1&lt;/td&gt;
&lt;td&gt;Openness&lt;/td&gt;
&lt;td&gt;I have a vivid imagination&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;o2&lt;/td&gt;
&lt;td&gt;Openness&lt;/td&gt;
&lt;td&gt;I prefer variety over routine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;c1&lt;/td&gt;
&lt;td&gt;Conscientiousness&lt;/td&gt;
&lt;td&gt;I am organized and pay attention to details&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;c2&lt;/td&gt;
&lt;td&gt;Conscientiousness&lt;/td&gt;
&lt;td&gt;I tend to leave things until the last minute (reverse)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;e1&lt;/td&gt;
&lt;td&gt;Extraversion&lt;/td&gt;
&lt;td&gt;I feel energized around other people&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;e2&lt;/td&gt;
&lt;td&gt;Extraversion&lt;/td&gt;
&lt;td&gt;I prefer quiet, solitary activities (reverse)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a1&lt;/td&gt;
&lt;td&gt;Agreeableness&lt;/td&gt;
&lt;td&gt;I trust people easily&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a2&lt;/td&gt;
&lt;td&gt;Agreeableness&lt;/td&gt;
&lt;td&gt;I can be cold and uncaring (reverse)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;n1&lt;/td&gt;
&lt;td&gt;Neuroticism&lt;/td&gt;
&lt;td&gt;I worry about things more than others&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;n2&lt;/td&gt;
&lt;td&gt;Neuroticism&lt;/td&gt;
&lt;td&gt;I stay calm under pressure (reverse)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&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%2F9hvgp6dm68pme5jpm60d.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%2F9hvgp6dm68pme5jpm60d.png" alt=" " width="800" height="407"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Claude correctly identified which items should be reverse-scored and marked them with "(reverse)" in the title. It didn't have a &lt;code&gt;--reverse&lt;/code&gt; flag to set (the MCP tools don't expose reverse-scoring configuration yet), so it used textual cues.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Elapsed: 0:22&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool Call 14: field_list (0:22)
&lt;/h2&gt;

&lt;p&gt;Before publishing, Claude called &lt;code&gt;field_list&lt;/code&gt; to verify all 10 fields were there.&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;Claude → MCP&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;field_list&lt;/span&gt;
  &lt;span class="s"&gt;appId&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a8f3k2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;Server&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="err"&gt;o&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;o&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;c&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;c&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;e&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;e&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;a&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;a&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;n&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;n&lt;/span&gt;&lt;span class="mi"&gt;2&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;10 fields confirmed. This is a verification step — Claude checking its own work before moving to the publish phase.&lt;/p&gt;

&lt;p&gt;Round-trip time: ~700ms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Elapsed: 0:23&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool Call 15: share_publish (0:23)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;Claude → MCP&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;share_publish&lt;/span&gt;
  &lt;span class="s"&gt;appId&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a8f3k2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;Server&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;code:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;data:&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;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;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 form was live. The &lt;code&gt;formDay: 3650000&lt;/code&gt; is the hardcoded "never expires" default — the AI doesn't need to decide on an expiry date.&lt;/p&gt;

&lt;p&gt;Round-trip time: ~900ms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Elapsed: 0:24&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool Call 16: share_url (0:24)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;Claude → MCP&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;share_url&lt;/span&gt;
  &lt;span class="s"&gt;appId&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a8f3k2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Server response: "https://formlm.me/s/a8f3k2"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The MCP server constructs the full URL by combining the server's base URL with the relative path returned by the API. Claude received the URL and displayed it to me.&lt;/p&gt;

&lt;p&gt;Round-trip time: ~500ms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Elapsed: 0:25&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Total
&lt;/h2&gt;

&lt;p&gt;From first keystroke to live URL: &lt;strong&gt;25 seconds&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;16 tool calls. 6 round-trips to the server for API operations (app create, schema, config, field add ×10 batched mentally, field list, publish, URL). Total server processing time: ~7 seconds. Total Claude thinking time: ~18 seconds.&lt;/p&gt;

&lt;p&gt;The form was live, accessible, and accepting responses. 10 fields, 5-point Likert scale, all 10 items present and correctly ordered.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Missing
&lt;/h2&gt;

&lt;p&gt;The form worked, but it wasn't a complete assessment. Here's what was missing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No scoring.&lt;/strong&gt; The form collected answers but didn't compute trait scores. Each trait has 2 items, and 4 of them are reverse-scored — but the scoring engine needs that information configured in the Scale module, which the MCP tools didn't expose at the time. So the form was a data collection tool, not an assessment engine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No dimension labels.&lt;/strong&gt; The fields have IDs like &lt;code&gt;o1&lt;/code&gt;, &lt;code&gt;o2&lt;/code&gt;, &lt;code&gt;c1&lt;/code&gt;, &lt;code&gt;c2&lt;/code&gt; — meaningful to me, but not to the system. The dimension mapping (o1+o2 = openness score) needs to be configured separately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No report template.&lt;/strong&gt; When someone completes the assessment, they get a "thank you" page, not a personality profile. The report generation is a separate module.&lt;/p&gt;

&lt;p&gt;So 25 seconds gets you a live form. Getting a live &lt;em&gt;assessment&lt;/em&gt; — with scoring, dimensions, and a generated report — took another 5-10 minutes of manual configuration in the web UI. Still fast, but not the "one sentence to full assessment" dream.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Tool Call Breakdown
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;Tool Calls&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Round-trips&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;App creation&lt;/td&gt;
&lt;td&gt;1 (app_create)&lt;/td&gt;
&lt;td&gt;4s&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema discovery&lt;/td&gt;
&lt;td&gt;2 (field_schema, field_config)&lt;/td&gt;
&lt;td&gt;2s&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Field creation&lt;/td&gt;
&lt;td&gt;10 (field_add ×10)&lt;/td&gt;
&lt;td&gt;16s&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verification&lt;/td&gt;
&lt;td&gt;1 (field_list)&lt;/td&gt;
&lt;td&gt;1s&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Publishing&lt;/td&gt;
&lt;td&gt;2 (share_publish, share_url)&lt;/td&gt;
&lt;td&gt;2s&lt;/td&gt;
&lt;td&gt;2&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;16&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;25s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;16 round-trips in 25 seconds. That's roughly 1.5 seconds per round-trip, which includes network latency, server processing, and Claude's thinking time between calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Slowed It Down
&lt;/h2&gt;

&lt;p&gt;Nothing, really. This was a clean run — no errors, no retries, no duplicate fields. The 25 seconds was almost entirely Claude thinking and the network round-tripping.&lt;/p&gt;

&lt;p&gt;The slowest part was the 10 &lt;code&gt;field_add&lt;/code&gt; calls. Each one is a separate API call. If I added a batch &lt;code&gt;field_add_many&lt;/code&gt; tool that accepted an array of fields in one call, I could cut the round-trips from 16 to 7, and the total time from 25 seconds to maybe 12 seconds.&lt;/p&gt;

&lt;p&gt;But batching has a downside: if one field in the batch fails, do you fail the whole batch or skip the failed one? I haven't figured out the right error semantics yet, so I'm keeping individual field_add for now.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Honest Answer
&lt;/h2&gt;

&lt;p&gt;"How long does it take?" — 25 seconds for a 10-field form, from sentence to live URL. That's the honest answer.&lt;/p&gt;

&lt;p&gt;But 25 seconds gets you a form. A real assessment — with scoring, reverse items, dimensions, and a report — used to take another 5-10 minutes of manual work. &lt;/p&gt;




&lt;p&gt;&lt;em&gt;The formlm-cli MCP server can build and publish forms in under 30 seconds. 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>productivity</category>
      <category>automation</category>
      <category>architecture</category>
    </item>
    <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;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;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;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;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;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>
  </channel>
</rss>
