Behind the scenes

How DataToForm Was Built — Story and Technical Architecture

This isn't a fundraising story or a strategic pivot story. It's the story of a mundane problem I saw repeat too often, and a series of technical decisions I made — sometimes well, sometimes with hindsight.

The problem nobody names

At some point, I watch an HR coordinator spend twenty minutes filling out an onboarding form in their HRIS. She has her Excel spreadsheet in front of her, she copies "Martin", pastes, copies "12/03/1987", pastes, copies the IBAN, pastes. For one single employee. She has twelve to process this morning. And tomorrow there will be health insurance forms, retirement plan forms, IT access requests...

This problem has no name in existing tools. Zapier says "we'll connect your apps." Make says "we'll build a workflow." RPA tools say "we'll code a robot." But here, we're talking about a person with an Excel file and a web form with no API. No webhook. No connector. A human, a file, a URL.

The question I ask myself: can we automate the copy-paste without removing the human from the equation?

The first prototype: a Playwright script

The first code I write is a Playwright script. Playwright is Microsoft's library for programmatically controlling a Chromium browser — the same browser a human would use, not a simple HTML parser. This is the first key decision: don't scrape the static DOM, but open a real window, with full JavaScript rendering, dynamic elements, React or Vue components that generate client-side.

The basic script fits in about fifty lines:

  1. Open the URL in Chromium
  2. List visible interactive elements (input, select, textarea)
  3. For each element, retrieve its label, placeholder, name, id
  4. Manually map Excel column → CSS selector
  5. Fill via page.fill(), page.selectOption(), page.check()

It works. On the test form. For five minutes. Then we try another form.

The hidden complexity of web forms

The diversity of web forms is an abyss. What we imagine as "a text field" actually hides dozens of variants:

  • Cascaded selects: selecting "France" in "Country" reveals "Île-de-France" in "Region" which reveals "Paris" in "Department". Each selection is an asynchronous event that triggers a partial reload.
  • Custom date pickers: the native input[type="date"] is often replaced by a JavaScript component with its own calendar. page.fill() does nothing.
  • Hidden fields: some HRIS forms hide fields until the form is in a specific state (active tab, wizard step...).
  • Disguised selects: <div> elements that behave like <select>, with ARIA roles and keyboard navigation, but no <option> in the DOM.
  • Autocomplete fields: typing in a field triggers an API request, a dropdown appears, you have to click on it.

The form-inspector.js that scans forms has progressively become more sophisticated: it filters visible elements (no display:none, no visibility:hidden, no opacity:0), it deduces the label associated with a field by traversing up the DOM (label[for], aria-label, aria-labelledby, parent text), it scores each detected field by its likelihood of being relevant.

AI in all this — where, why, and especially how

The question of column-to-field mapping is where AI enters the picture. How do you know that the "LAST NAME" column should go in the "last_name" field, and that "Birth date" corresponds to "Birthdate" on an English-language form? That's natural language, semantics — an LLM is built for that.

But there's a rule I set from the start, non-negotiable: the AI never sees the actual values of your data. It receives only:

  • The list of column names from the Excel file (["First name", "LAST NAME", "Date_of_birth", "IBAN", ...])
  • The list of fields detected in the form (["first_name", "last_name", "dob", ...] with their labels)

It returns a mapping JSON. That's it. The 500 rows of your Excel file never go through an AI server.

The dual-LLM architecture for Excel

For complex Excel files — non-tabular spreadsheets, merged cells, titles mid-table, data spread across multiple tabs — a single LLM call isn't enough. I designed a two-step pipeline:

  1. First call (extractExcelData): we give the LLM the raw text of the Excel (a few hundred characters, never the sensitive values). It identifies which rows are headers, where the data table starts, which columns are relevant. It returns a structured object.
  2. Second call (suggestMapping): with the clean header list extracted, we suggest the mapping to the form fields.

For simple files (a table with headers in row 1), the first call is nearly trivial. For a complex HR document with sections, titles, and subtotals, it makes the difference between a successful mapping and a mess.

Shared rate limiting

Both LLM calls share the same rate-limiting counter (checkRateLimit() in lib/llm.js). Default: 20 requests per minute, per IP. This isn't an arbitrary constraint — it's the reality of free or cheap LLM APIs. And it forces you not to multiply unnecessary calls.

The most important architectural decision

Early on, I make a decision that some find constraining: DataToForm will never submit a form. No optional "auto-submit" parameter, no unsupervised mode. By design.

The fillCurrentPage() function in generic-form-filler.js fills the fields, then stops. It doesn't accept a submit selector. If you look at the /api/fill-current-row API, there's no submit parameter in the schema. This isn't an oversight — it's deliberate.

Why? Three reasons:

  1. Legal liability. A wrong tax declaration, an incorrect IBAN, an incomplete insurance claim: the consequences of automatic submission can be serious. The human must validate.
  2. Trust is built incrementally. A tool that never submits without your approval is a tool you can try without anxiety. It's also a commercial argument — but it's primarily a conviction.
  3. AI agents do the opposite. Browser Use, Skyvern, Operator submit automatically. It's not that they're poorly designed — it's their use case. Ours is different: bulk Excel data, with row-by-row review.
An LLM in agent mode can hallucinate a value and submit it. DataToForm cannot hallucinate a value because it doesn't reason during filling — it mechanically applies your validated mapping.

The tech stack and why these choices

Node.js ESM, no heavy server framework

The backend is Node.js with native ES modules (import/export rather than require). Express for HTTP routing. Multer for file uploads. No "batteries included" framework — each dependency is there for a precise reason.

Playwright Chromium, not Puppeteer

Playwright is more robust than Puppeteer for selector handling, element waiting, and iframe support. It natively handles waitForSelector with configurable timeouts, and its API for cascaded selects (page.selectOption() with click fallback) is more reliable.

Headless Chromium on the server, but with an option to open a real visible window when the user needs to log in themselves. This detail — the visible window for manual login — matters: no credentials ever go through the server.

WebSocket for real-time progress

Analyzing a form can take 5 to 15 seconds. The LLM can take 3 to 8 seconds. We send events to the client via WebSocket (ws) throughout the process: "Opening browser", "Analyzing fields", "AI at work"... It's UX feedback, but also a signal that the system is alive and progressing.

SheetJS for Excel — and why two LLM calls

SheetJS (xlsx) parses Excel files. It's fast, supports .xlsx, .xls, and .csv, and works in memory (the file is never written to disk). For classic tabular structures, XLSX.utils.sheet_to_json() gives a clean array in one line. For complex structures, we extract raw text with sheet_to_csv() and give it to the first LLM to reconstruct the structure.

Vanilla JS on the client — zero framework, zero bundler

The frontend is plain JavaScript. No React, no Vue, no Vite, no Webpack. One app.js file, one style.css file, one index.html file. Native ES modules in the browser (import in <script type="module">) replace bundlers.

This choice radically simplifies the development cycle: edit a file, refresh the browser, see the result. No npm run build, no source maps to configure, no hot module replacement to debug.

Multilingual from the start

FR, EN, ES. Not an afterthought — an initial design constraint. Each blog article has its own localized slugs with hreflang injected server-side. Language selection stored in a cookie (dtf_locale), not localStorage, for compatibility with server-side rendering.

Where things stand

DataToForm is in open beta. The first row of each document is processed for free — this is a FREE_ROW_LIMIT = 1 constraint in server.js, not a client-side limit that could be bypassed.

What's still missing and in progress:

  • Cascaded selects (the logic is in select-utils.js, but some custom components still resist)
  • Multi-page forms (wizard steps) — fill one page, wait, continue
  • Google Sheets as a data source in addition to Excel
  • Mapping memorization between sessions on the same form

What won't change:

  • The AI will never see your actual values
  • The form will never be submitted automatically
  • The file will never be stored on the server

If you have a specific use case or feedback on a form that DataToForm can't fill correctly, the Contact page is there for that. The tool improves with real cases.

Try it with your first file

Upload your Excel, enter the form URL. DataToForm pre-fills the first row for free.

Start for free

← Back to blog