{"slug": "designing-forms-an-ai-agent-can-actually-submit", "title": "Designing Forms an AI Agent Can Actually Submit", "summary": "FORMLOVA, a chat-first form service, has redesigned its forms to be reliably submittable by AI agents, not just humans. The company identified five key requirements for agent-friendly forms, including stable semantic field identifiers and validation rules accessible before submission. FORMLOVA exposes these properties through both tool calls and rendered pages, ensuring compatibility with desktop clients, MCP-aware agents, and browser automation tools.", "body_md": "Most form codebases I have read were designed against one mental model of the submitter.\n\nA person.\n\nA person who reads each label.\n\nA person who watches the screen between submit and the confirmation banner.\n\nA person whose retries look like fast double clicks, not like a queued workflow that came back online.\n\nA person whose definition of bot is \"obviously not a person.\"\n\nThat mental model is still correct for a lot of traffic. It is becoming less correct over time.\n\nIncreasingly, the entity submitting a form is an AI agent acting on behalf of a person. Desktop clients with tool calling, MCP-aware agents, browser automation agents, and computer-use models all submit forms on someone's behalf. The form on the other side rarely knows.\n\nThis article is about what to change in your form so an agent can submit it reliably, without you having to ship an API just for them.\n\nI will use [FORMLOVA](https://formlova.com/en) as the working example, because that is the codebase I work in. FORMLOVA is a chat-first form service whose primary surface is an MCP server (129 tools across 25 categories) and whose secondary surface is a hosted form page. Operators and respondents can both reach it via chat or page; in both cases, at least one side of the interaction can be an agent.\n\nThe patterns themselves are not FORMLOVA-specific.\n\nBefore talking about code, it helps to fix the requirements.\n\nAn agent submitting on behalf of a user typically needs five things from your form:\n\n```\n1. A way to identify each field by meaning, not by pixel position.\n2. A way to learn the validation rules before sending values.\n3. A way to submit safely if the network blinks or the user retries.\n4. A way to read the confirmation result without depending on a toast.\n5. A way to prove it is legitimate without solving an \"are you a human\" puzzle.\n```\n\nIf any of those five are missing, the agent will either fail silently or burn user trust with retries. Neither is a good outcome for your conversion rate.\n\nThe interesting product question is not \"should we have these properties.\" It is \"which of these can we expose as a tool call, and which has to live in the rendered page so that browser agents can still operate the form from outside our walls.\"\n\nIn FORMLOVA, the answer is hybrid: each property is exposed both ways. The tool surface is the canonical contract. The rendered page mirrors it.\n\nThe first thing to fix is the field identity.\n\nA field name like `field_3`\n\nor `q_short_answer_12`\n\nworks for a renderer. It does not work for an agent.\n\nThe agent needs a stable, semantic identifier that survives layout redesigns.\n\n```\ntype FieldDescriptor = {\n  // Stable across the life of the response. Never recycled.\n  stableId: string;\n  // Semantic name reused across forms. e.g. \"email\", \"company\", \"consent_marketing\".\n  semanticName: string;\n  // Position-only id used for current rendering.\n  renderId: string;\n  label: { default: string; locales?: Record<string, string> };\n  type: \"text\" | \"email\" | \"phone\" | \"url\" | \"select\" | \"checkbox\" | \"date\" | string;\n  required: boolean;\n  validation?: ValidationRule[];\n  helpText?: string;\n};\n```\n\nTwo practical rules:\n\nThe `semanticName`\n\nis what agents reason about. Pick a small vocabulary and reuse it across forms.\n\nThe `id`\n\nis what the submission payload references. Do not regenerate it on each publish, or you will silently break agents that cached the previous shape.\n\nIn FORMLOVA, the form schema has 29 field types (text, textarea, number, radio, checkbox, dropdown, date, datetime, time, email, phone, url, file_upload, matrix, signature, address, rating_scale, NPS, linear_scale, slider, opinion_scale, ranking, picture_choice, yes_no, country, legal, statement, section_break, hidden_field). Every one of them carries a stable id, a semantic name, and a snapshot of the label that was active at the time the response was collected. An agent that wants to fill a yes_no field can do so by semantic name without parsing the rendered UI.\n\nIf your form lives behind an MCP server, the same descriptors should be returned as part of the form's tool schema. Agents will read it. Treat it as a public contract.\n\nAgents are good at filling values. They are less good at guessing your invisible rules.\n\nThe classic anti-pattern is rejecting a submission with a friendly error message, but never publishing the rule that produced it. A human reader reads the message and retries. An agent has to round-trip every guess.\n\nA small contract avoids that:\n\n```\ntype ValidationRule =\n  | { kind: \"required\" }\n  | { kind: \"min_length\"; value: number }\n  | { kind: \"max_length\"; value: number }\n  | { kind: \"pattern\"; regex: string; description: string }\n  | { kind: \"enum\"; values: string[] }\n  | { kind: \"domain_allow\"; suffixes: string[] }\n  | { kind: \"duplicate_prevention\"; window: \"form\" | \"user\" | \"off\" };\n```\n\nIf the form publishes the rules, an agent can:\n\nThe rules do not have to be exhaustive. They have to be honest. A rule the form silently enforces but does not declare is a contract violation against the agent reader.\n\nFORMLOVA publishes the duplicate prevention rule explicitly. When a form is created or updated, the operator picks between \"email-based duplicate prevention,\" \"cookie-based duplicate prevention (same browser),\" and \"no duplicate prevention.\" The choice is part of the form's published contract. An agent attempting to submit twice for the same intent can know in advance whether the second submission will be accepted or treated as a duplicate.\n\nA similar choice is offered for conditional logic. The workflow engine in `lib/webhooks/workflows.ts`\n\nuses the operators `eq`\n\n, `neq`\n\n, `gt`\n\n, `gte`\n\n, `lt`\n\n, `lte`\n\n, `contains`\n\n, `not_contains`\n\n, `is_empty`\n\n, `is_not_empty`\n\n, with logical combinators `all`\n\n(AND) and `any`\n\n(OR). These operators are part of the form's published behavior, so an agent can predict what will happen when its submission lands.\n\nNetwork blinks happen. So do user-side retries. So do queue replays in agent platforms.\n\nIf your form treats every POST as a new intent, you will create duplicates the user did not ask for.\n\nThe fix is small:\n\n```\ntype FormSubmission = {\n  formId: string;\n  values: Record<string, unknown>;\n  submissionIntentId: string;\n  submittedOnBehalfOf?: {\n    actorType: \"human\" | \"agent\" | \"automation\";\n    consentToken?: string;\n  };\n};\n```\n\nThe `submissionIntentId`\n\nis generated by the submitter, not the server. It should be a UUID the agent assigns once per intent. The server uses it as the deduplication key.\n\nOn the server side:\n\n``` js\nasync function submitForm(input: FormSubmission) {\n  const existing = await db.responses.findByIntentId(input.submissionIntentId);\n  if (existing) {\n    return { status: \"duplicate\", canonical: existing };\n  }\n  const created = await db.responses.create({\n    ...input,\n    receivedAt: new Date(),\n  });\n  return { status: \"created\", canonical: created };\n}\n```\n\nYou do not need a distributed lock for most form traffic. You need a unique index on `(form_id, submission_intent_id)`\n\nand a clear status response so the agent can decide what to do next.\n\nA user filling once and clicking submit twice should produce one record. An agent retrying after a 502 should also produce one record. Same mechanism, both cases.\n\nFORMLOVA goes one step further on the server side. The capacity check for forms with a participant limit uses a PostgreSQL RPC called `insert_response_with_capacity_check`\n\nthat combines `SELECT ... FOR UPDATE`\n\nand `INSERT`\n\nin the same transaction. The intent id deduplication and the capacity check are both single-trip operations, so agents do not have to choose between throughput and correctness.\n\nThe respondent identifier is also reused as a long-term key. Every response carries a `respondent_identifier`\n\nderived either from the email address (when one was submitted) or from a salted hash of IP and user agent (when one was not). This means that a respondent retrying through a different network still resolves to the same identifier when an email was provided, and the agent can avoid creating duplicate respondent profiles.\n\nToast notifications are great for humans. They are useless for agents.\n\nIf the confirmation only exists for a few seconds in a client-side animation, the agent has to guess whether submit succeeded.\n\nThe confirmation surface for an agent should be:\n\nA minimum shape:\n\n```\ntype SubmitResponse = {\n  status: \"created\" | \"duplicate\" | \"rejected\";\n  responseId?: string;\n  canonicalUrl?: string;\n  fieldErrors?: Array<{ id: string; rule: string; message: string }>;\n  postSubmit?: {\n    autoReplyScheduled: boolean;\n    redirectUrl?: string;\n    expectedFollowUp?: \"email\" | \"review\" | \"none\";\n  };\n};\n```\n\nThat structure makes it possible for an agent to tell its user, \"your submission was recorded as ABC123. An email confirmation will arrive shortly. The team typically responds within two business days.\" That sentence is the actual product surface, not the toast.\n\nA common mistake here is to conflate \"auto-reply enabled\" with \"auto-reply delivered.\" They are not the same. FORMLOVA treats the auto-reply state as a small enum: `not_required`\n\n, `pending`\n\n, `sent`\n\n, `failed`\n\n. The submit response can include the current state, and a follow-up tool call can re-read it after the asynchronous email job completes. The agent never has to guess.\n\nThe thank-you page state has the same shape. The respondent saw a thank-you page does not mean the team has received and acknowledged the response. FORMLOVA's thank-you pages support both a basic message and a structured `blocks`\n\nshape (text, image, button, link, video, divider; up to twenty entries) with conditional logic. An agent reading the form's published shape can know in advance whether the thank-you page will say \"received\" or \"received and routed to your account manager,\" and it can mirror that statement to the user.\n\nThis is the place most teams get wrong, and it is the one that hurts conversion the most.\n\nA captcha designed around \"is this a human\" treats any agent as an attacker. That is the wrong question for the next few years.\n\nThe right question is, \"is this submission a legitimate intent.\"\n\nA legitimate agent submission has properties a fraud submission usually does not:\n\nA reasonable policy is to keep a friction layer for unknown sources, and to relax it for actor-declared submissions whose history looks legitimate:\n\n```\nasync function defenceCheck(input: FormSubmission, request: Request) {\n  if (input.submittedOnBehalfOf?.actorType === \"agent\" && hasValidConsentToken(input)) {\n    return passSoftCheck(request);\n  }\n  if (looksLikeBrowserAutomation(request) && !input.submittedOnBehalfOf) {\n    return blockOrChallenge(request);\n  }\n  return defaultTurnstileCheck(request);\n}\n```\n\nThe point is not the exact rules. The point is that \"all non-humans are bad\" stops being the right default. The category of \"non-human submitter the user explicitly delegated to\" needs a different lane.\n\nFORMLOVA already maintains a separate lane for the sales-pitch problem. After submit, each response on forms with `spam_filter_enabled = true`\n\nis asynchronously classified into `legitimate`\n\n, `sales`\n\n, or `suspicious`\n\nby a lightweight OpenRouter-hosted model (about $0.0002 per response). This classification does not block the submit; it shapes downstream analysis, filtering, and ownership. The architectural lesson generalizes: do not refuse the submission at the wall. Refine it after, with state on the record. The same lesson applies to agent submitters: route them, do not block them, and let the workflow downstream decide.\n\nThis is the part most relevant when the agent is on the operator side rather than the respondent side, but it shapes the entire MCP contract and is worth covering.\n\nSome operations are externally irreversible: sending bulk emails, replying to a respondent, deleting a form, unpublishing a form, removing a team member, ending an A/B test, deleting a response, restoring a previous form version. If an MCP client tells a model \"you can call any tool,\" and the model decides to call `send_bulk_email`\n\n, the cost of being wrong is high.\n\nFORMLOVA classifies tools into four levels:\n\n```\nL0  read-only           execute immediately\nL1  reversible write    execute immediately (version history covers the rollback path)\nL2  respondent-facing   server-side review state machine (publish_form)\nL3  externally final    HMAC-signed confirmation_token required\n```\n\nThe eleven L3 tools and the L2 `publish_form`\n\nall require a `confirmation_token`\n\nsigned with HMAC-SHA256 and valid for 5 minutes. The token is issued by the server only after the model has presented the right summary to the user and the user has explicitly approved. Even if the model misreads its own instructions, it cannot bluff its way past the gate without a current token.\n\nThis matters for agent submitters too, because the same form can be operated from the respondent side and the operator side, and we want the agent to feel exactly as safe to talk to as a careful intern. The contract is \"you can read freely, you can write reversibly, anything else needs a fresh user-signed token.\"\n\nIf you are already running an MCP server, this is the part that ties it together.\n\nThe form should not only exist as a rendered page. It should also exist as a tool an MCP client can call.\n\nA first cut of the tool surface:\n\n``` php\nlist_forms                -> Returns form descriptors.\nget_form_schema(formId)   -> Returns fields and validation rules.\nsubmit_form(formId, ...)  -> Returns SubmitResponse, including duplicate handling.\nget_form_status(formId)   -> Returns whether the form is open, capped, scheduled.\n```\n\nThat is the minimum the agent reader needs. Operator-side tools like response search, status updates, exclusions, and reports are a separate concern.\n\nIn FORMLOVA, this minimum surface lives in the `forms`\n\ncategory, with adjacent categories handling the read side (`responses`\n\n, `pulse`\n\n), the response-management side (`response-management`\n\n, `filtering`\n\n), and the workflow side (`webhooks`\n\n, `email-sequences`\n\n, `scheduling`\n\n, `smart-notifications`\n\n). An agent that wants to operate a form has a stable categorical map of what is available, not a flat list of 129 verbs to guess at.\n\nI want to call out one place where the abstract advice can mislead.\n\nThe argument is not \"expose everything as MCP and delete the dashboard.\" It is \"the canonical operational surface should not depend on a person scanning the dashboard.\"\n\nFORMLOVA's dashboard is still there, because some tasks are better done visually:\n\nThe honest claim is that the chat (MCP) surface and the dashboard are different shapes of the same product. Chat is sequential and great for intent-driven workflows (\"show me responses from this week, exclude sales pitches, draft a reply to the three demo requests\"). The dashboard is parallel and great for scanning. An agent submitter benefits from the same architecture, because the form's published shape is the same shape the dashboard renders.\n\nThis pattern does not solve fraud entirely. It just stops treating \"agent\" and \"fraud\" as the same word.\n\nIt does not solve consent. You still need to record who delegated to the agent, and how. A consent token is a placeholder for a real consent record, not a substitute for one.\n\nIt does not solve every accessibility case. Screen readers and AT consumers also benefit from semantic field naming, but they have their own contract you should still meet.\n\nIt does not solve the visual side. Humans are still submitting too, and the visual design still matters for them.\n\nWhat it does is stop the form from quietly failing for an audience that will keep growing.\n\nIf you only do five things from this article, do these:\n\n```\n1. Give every field a stable semantic name in addition to a render id.\n2. Publish your validation rules in a machine-readable form.\n3. Accept a submitter-assigned intent id and deduplicate on it.\n4. Return a structured confirmation in the submit response, not only in UI.\n5. Stop using \"are you a human\" as your only bot signal.\n```\n\nIf you can do five more, do these:\n\n```\n6. Classify side effects by blast radius (L0..L3) and require confirmation tokens for the irreversibly final ones.\n7. Surface the auto-reply / notification / classification state on the response record, not in a separate notification log.\n8. Track the respondent across forms with a stable identifier that does not depend on which fields a given form happened to ask for.\n9. Expose form, schema, submit, and status as MCP tools so agents can talk to your product without scraping.\n10. Keep the visual surface honest with the agent surface. Both are reading the same form.\n```\n\nEach is a small change in isolation. Together they change what kind of submitter your form is honest with.", "url": "https://wpnews.pro/news/designing-forms-an-ai-agent-can-actually-submit", "canonical_source": "https://dev.to/lovanaut55/designing-forms-an-ai-agent-can-actually-submit-4352", "published_at": "2026-05-28 06:09:35+00:00", "updated_at": "2026-05-28 06:23:24.480432+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "ai-products", "ai-infrastructure", "ai-research"], "entities": ["FORMLOVA", "MCP"], "alternates": {"html": "https://wpnews.pro/news/designing-forms-an-ai-agent-can-actually-submit", "markdown": "https://wpnews.pro/news/designing-forms-an-ai-agent-can-actually-submit.md", "text": "https://wpnews.pro/news/designing-forms-an-ai-agent-can-actually-submit.txt", "jsonld": "https://wpnews.pro/news/designing-forms-an-ai-agent-can-actually-submit.jsonld"}}