If you automate forms on other people's websites, the hard part isn't filling a field. It's that every site is different and keeps changing. This page covers how we built around that, what the numbers look like, and the calls I'd make again.
Dashboard figures as of October 2026. Live since March 2026, used by three regional operations teams.
The problem
Our operations teams submit the same kind of record to a long list of external application portals. No two portals behave alike. Field names differ. Page order differs. One wants the date as 14/07/1999, the next wants 1999-07-14. Some ask for a one-time code halfway through. Some show a whole extra section only if you tick a box three pages earlier.
By hand, one submission took about 30 minutes of careful retyping. Multiply that by thousands and you get a team doing copy-paste for a living, plus the errors that come with it.
I'd done this the other way before. At S&P Global I automated 40+ state portals with one script per site. It saved close to 3 FTE of work, but every redesign on any one of those sites meant a code fix, a test and another deployment. With one bot per site you don't maintain one automation. You maintain forty.
The idea: describe the portal, don't program it
So we flipped it. There's one engine that knows how to read and fill a web form. Each portal is a config file that says where each field lives, what value goes in it, and what "done" looks like. Adding a portal means writing a config. A portal changes its layout? Update the config. The engine doesn't move.
Here's the shape of a config, heavily simplified. The real ones are longer, but they're still data.
{
"portal": "portal-b",
"pages": [
{ "name": "personal", "steps": [
{ "fill": "input[name=full_name]", "from": "name.last_first" },
{ "fill": "#birth-date", "from": "dob", "format": "YYYY-MM-DD" },
{ "select": "#nationality", "from": "country.iso3" },
{ "if": { "visible": "#visa-section" },
"then": [ { "fill": "#visa-number", "from": "visa.number" } ] },
{ "handover": "otp", "note": "code is emailed to the applicant" }
], "done_when": { "visible": "text=Education history" } }
]
}
Illustrative only. Not a real portal or our real schema.
How one job runs
An operator picks a record and a portal from a browser side panel. That's the only click they make until the bot needs them.
The API (FastAPI) checks the job and puts it on a queue (Celery with RabbitMQ). The engine, written in Node.js on Playwright, picks it up, loads the portal's config and works through the pages. Redis holds sessions and results. MongoDB holds the configs, the jobs and a log of every step. It all runs in Docker on AWS ECS Fargate, with separate staging and production clusters.
Calls I made, and why
Build the editor, not just the engine
Every portal's config lives in MongoDB, not in the codebase. We built our own workflow editor for it. A portal is a chain of sections, each with a check that the right page is showing and a "continue" button to press. Inside each section are steps: type, select, click, date pickers, yes/no answers, radio groups, waits, manual pauses and repeat steps. There's a JSON view for anything the editor doesn't cover.
The config list doubles as a health board. Each portal shows whether it's live, who changed it last, and its own success rate, so a portal that starts failing stands out before anyone files a ticket. Editing a portal is a save in the editor, not a release.
Clean the data once, before the bot starts
Our CRM keeps a full name in one field, countries as codes and dates in one format. Every portal wants something different. So a Python step runs first and turns raw CRM values into portal-ready values: names split and reordered, emails trimmed and lower-cased, country codes turned into names or three-letter codes, yes/no answers normalised, phone numbers given a country prefix.
Two good things fall out of this. The bot never touches messy data. And a missing field shows up before the run starts, not on page six of a portal with a session about to time out.
Let the page decide which questions apply
Portals show and hide questions based on earlier answers. Writing a separate config for every path through a form would have multiplied the work. Instead the config has a small logic layer: do these in order, do this only if an element is on the page (or visible, or the previous step worked), and repeat a section until the list runs out. That last one handles things like "add another education entry" without anyone counting rows in advance.
Design the human steps in from day one
OTPs, CAPTCHAs and document uploads go to a person, on purpose. The bot pauses, the operator does that step, and the bot picks up where it stopped. Trying to automate those steps would have made the whole system fragile, and some of them shouldn't be automated at all. I wrote more about this in designing the human steps in.
Small failures retry on their own. A slow page gets a longer wait. If the bot can't find the next section, it pauses so the operator can clear whatever's blocking it, then tries again, up to a fixed limit.
Give every failure a label
When a run fails, the platform reads the error and tags it. That tag decides who fixes it.
A config error is ours. A site change means a portal moved something, and the config needs an update. A connection issue usually fixes itself on a retry. "Needs a person" means the bot hit something it shouldn't guess at.
Define success so it can't flatter us
Success rate is successful jobs divided by successful plus failed. If an operator stops a job on purpose, maybe because the record was wrong, that isn't a platform failure and it doesn't count against us. It doesn't count for us either. I explain the trade-off, with the numbers both ways, in how I measure RPA success rate.
Results
As of October 2026 the engine has handled 3,000+ submissions across 70+ portals at a 94% success rate. Coverage grew to 71 configured portals while the engine stayed one codebase. Not one of those portals needed a redeploy to add. By hand a submission takes about 30 minutes. The bot's own time averages about 5 minutes per job. Together that's 1,200+ hours of manual work handed back to the teams.
That 5 minutes is bot time only. The wall-clock time of a job is longer, because it includes the pauses where a person enters a code or gets documents ready. I don't fold those pauses into the bot's number, and I don't count them as savings.
What I'd tell someone building this
- Logic goes in the engine, differences go in the config. If you're writing site-specific code, ask why. Every portal we run is a config, stored in MongoDB and edited in our own workflow editor. Not one of them needed a redeploy to add, and config changes never need one.
- Make "done" explicit. A bot that can't tell success from a silent failure is worse than no bot.
- Guard the line. The first "quick special case" in the engine is how you end up back at forty scripts. Holding that line was most of my job.
Stack
Python, Node.js, Playwright, FastAPI, Celery, RabbitMQ, Redis, MongoDB, React, a Chrome extension for the side panel, Docker, AWS ECS Fargate.
My role: I led the design and rollout, worked hands-on on the critical components, and set the rules above with a small engineering team. Portal names and internal details are left out on purpose. Figures are from the live dashboard, October 2026.