We automate form filling with zero LLM calls, and that is the feature

typescript dev.to

Our app has a feature that replies to a rental listing on the user's behalf, filling in whatever contact form that site happens to use. In 2026 the reflex answer is obvious: give a model the DOM, ask it to fill the form, let it click submit.

We do not do that, and the reason is not cost or latency. It is that the action is irreversible and addressed to a stranger.

If the automation gets it wrong, a real human landlord receives a real message with the user's real name, phone number and email attached. You cannot un-send it. There is no undo, no confirmation dialog on the recipient's end, and the user finds out about the mistake when someone replies to a message they did not write.

That failure mode does not tolerate "usually right".

Record once, replay exactly

So the mechanism is a macro recorder. The first time you reply to a listing on a given site, you do it yourself in a window we own, and we record what you did:

/**
 * Records the user replying to a listing, then turns that into a replayable
 * recipe. Runs in the Electron main process (it owns the BrowserWindow).
 *
 * There is no mapping layer. Whatever the user typed is what gets replayed, per
 * site — their name, phone, email, their message, and anything else that site
 * asked for.
 */
Enter fullscreen mode Exit fullscreen mode

That last paragraph is the design. There is no profile screen, no "your details" form, no template with {{name}} placeholders. The site already asked the user for exactly the data it needs, in exactly the format it wants, and the user already typed it. Asking them to fill in a separate profile as well is asking for the same data twice and introducing a mapping step that can be wrong.

The recorded steps become a recipe keyed by domain. Every later reply on that site is a replay.

Deterministic means it is allowed to refuse

/**
 * Replays a recorded recipe against a new listing.
 *
 * Everything here is deterministic. There is no guessing: if the page does not
 * match what was recorded, or any guard trips, we stop and report why rather
 * than improvising. Refusing to send is always the better failure.
 */
Enter fullscreen mode Exit fullscreen mode

"Refusing to send is always the better failure" is the line the whole module is organised around, and it is the one thing a model-driven version structurally cannot promise. A model's job is to produce an answer. Ours needs a component whose job is to produce no answer whenever the situation is not the one it was taught.

A recipe that no longer matches the page is a broken recipe, and a broken recipe is a prompt to re-record — thirty seconds of the user's time — not an invitation to improvise.

Three implementation details that turned out to matter

Budget the whole replay, not just each step.

const NAV_TIMEOUT = 30_000;
const STEP_TIMEOUT = 10_000;
/** Hard ceiling on a whole replay, so nothing can hang the poll loop. */
const TOTAL_BUDGET_MS = 120_000;
Enter fullscreen mode Exit fullscreen mode

Per-step timeouts do not bound total runtime — twelve steps that each take nine seconds is a two-minute replay where nothing timed out. The replay shares a process with the polling loop, so a hung replay is a monitor that stops noticing new listings. The ceiling is what makes the feature safe to run inline.

You cannot type into a date input.

const SEGMENTED_INPUTS = new Set([
  'date', 'month', 'week', 'time', 'datetime-local',
]);
Enter fullscreen mode Exit fullscreen mode

The browser renders these as separate day/month/year segments, not as a text field. Sending keystrokes gets you garbage or nothing. They have to be set by value, and they are common on rental forms ("available from"), so a replayer that does not special-case them fails on a large fraction of real sites.

A recorded date is a trap. Replay a literal 2026-03-01 six weeks later and you have submitted a move-in date that is already in the past — an application that reads as carelessly automated, which is precisely the impression the feature exists to avoid. So a captured value that looks like a date gets flagged at record time, and replay refreshes it instead of repeating it. That is the single piece of interpretation in the system, it is a regex, and it is auditable.

The honest trade

The recorder costs the user one manual reply per site. A model-driven version would cost them zero.

In exchange, every reply after that is exactly the shape of one they wrote themselves, the failure mode is a refusal rather than a wrong message to a stranger, and when it breaks you can read the recipe and see precisely what it was going to do. For an action you cannot take back, that trade is not close.

Try it against a form you own

The recorder and the replayer are both in the app — download it at notifio.app, log in, and record a reply against any contact form you control. Watch the recipe get built, then point it at a second listing on the same site and watch it replay.

The interesting bit is breaking it on purpose: change a field name on your form and re-run. It should stop and tell you which step no longer matches, and it should not send anything.

Source: dev.to

arrow_back Back to Tutorials