DEV Community

Keo Fung | FormLM
Keo Fung | FormLM

Posted on

The Boolean Trap: When Your AI Literally Cannot Say "False"

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.

Here's how a TypeScript boolean type and a commander.js flag convention teamed up to make false an impossible value.

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

The Setup

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.

I told Claude:

"Create a feedback form. Name and email are required. The comments field is optional. The 'how did you hear about us' field is optional."

Claude went to work. It created the app, added the name and email fields with required: true, and then... this happened.

What Claude Did

For the required fields, Claude called field_add with required: true. Clean, correct:

{
  "appId": "app_abc123",
  "id": "email",
  "type": "input",
  "title": "Email address",
  "required": true
}
Enter fullscreen mode Exit fullscreen mode

For the optional fields, Claude called field_add and simply... didn't include the required parameter:

{
  "appId": "app_abc123",
  "id": "comments",
  "type": "textarea",
  "title": "Additional comments"
  // no "required" field at all
}
Enter fullscreen mode Exit fullscreen mode

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.

The Root Cause

Here's the thing. In the MCP schema, required is defined as:

required: z.boolean().optional().describe('Mark as required'),
Enter fullscreen mode Exit fullscreen mode

z.boolean().optional() means the parameter accepts true, false, or undefined (not provided). When Claude wants to make a field optional, it has two options:

  1. Pass required: false — explicitly set it to false
  2. Don't pass required at all — leave it undefined

Claude chose option 2. And here's where the problem starts.

When required is undefined, the CLI builds the command string like this:

const required = normBool(opts.required);
if (required !== undefined) cmd += ` --required=${required}`;
Enter fullscreen mode Exit fullscreen mode

If required is undefined, the --required flag is never added to the command. The server receives the assess form add command without any --required parameter.

And the server's default behavior? When no --required flag is present, it defaults to true.

So "I didn't say required" became "required by default." Every optional field silently became mandatory.

Why Claude Didn't Pass false

I tested this. I explicitly told Claude: "Make the comments field NOT required. Set required to false."

Claude tried. It passed required: false in the MCP parameters. But here's what happened on the CLI side:

The normBool function in field.ts handles three cases:

function normBool(v: unknown): boolean | undefined {
  if (v === undefined) return undefined;
  if (typeof v === 'boolean') return v;
  return v === 'true' || v === '1';
}
Enter fullscreen mode Exit fullscreen mode

When the MCP server receives required: false (a proper boolean), it passes it through and builds:

assess form add --app <appId> --id comments --required=false --json
Enter fullscreen mode Exit fullscreen mode

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 false. When I checked the conversation log, Claude's reasoning was:

"The field is optional, so I don't need to set required."

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 true.

The Real Problem: Defaults That Bite

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

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

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

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.

The Fix

I tried three approaches before settling on one.

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

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

Attempt 3: Fix the schema description. This is what I went with. I changed the .describe() string to be explicit about the default:

required: z.boolean().optional().describe('Mark as required. Defaults to true if omitted. To make a field optional, explicitly set this to false.'),
Enter fullscreen mode Exit fullscreen mode

That one sentence — "To make a field optional, explicitly set this to false" — was the instruction Claude needed. After this change, Claude started passing required: false for optional fields instead of omitting the parameter.

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 required, 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.

The normBool Safety Net

The normBool function itself is fine — it correctly handles true, false, the string "false", and undefined. The problem was never in the function. It was in the gap between:

  • What the schema says (.optional() → "you can skip this")
  • What the server does (defaults to true when skipped)
  • What the AI assumes (skipping = not required)

Three layers, each reasonable in isolation, combine to produce a bug where "optional" silently means "required."

The Pattern I Now Follow

For any boolean parameter where false is a meaningful value, I:

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

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.


The formlm-cli MCP server has explicit descriptions for all boolean parameters. Open source at github.com/formlm/cli — try the platform at formlm.me.

Top comments (0)