💡 This is the template. When you create a bot from it, name the new repo
rpa-<process-name>(e.g.rpa-download-invoices).
Reference template for building web RPA bots in Python with Selenium or Playwright.
It defines one standard for:
- the folder structure,
- the layering rules,
- the naming conventions,
- error handling, retries and logging.
Both people and AI agents can follow the same standard.
One repo = one bot = one end-to-end business process. The bot is built for that process, and the process is split into tasks (ordered steps).
- Why this template
- Architecture
- Folder structure
- Layering rules
- Naming conventions
- Error handling and retries
- Logging and OpenTelemetry
- Quick start
- Switching engines
- Running in Azure DevOps and Airflow
- How to add a new screen
- How to add a new task
- Working with AI agents
RPA scripts tend to start as one long file full of selectors, time.sleep() calls and copy-pasted logins. They work until the website changes, and then nobody knows where to fix them.
This template applies the Page Object Model (POM) to RPA. It separates:
- what the bot does: tasks, the business process;
- where it does it: pages, the screens of the website;
- how it talks to the browser: core and engines, Selenium or Playwright.
When the website changes, you fix one page. When you change browser engine, you touch nothing outside core/engines/.
The editable diagram lives in docs/architecture.drawio. Open it with diagrams.net or with the Draw.io extension for VS Code.
flowchart TD
ENV["Environment variables<br/>(.env · Azure DevOps · Airflow)"] --> SETTINGS["config/settings.py"]
MAIN["main.py<br/>bootstrap"] --> SETTINGS
MAIN --> PROCESS["process.py<br/>ordered list of tasks"]
PROCESS --> TASKS["tasks/<br/>LoginTask → DownloadInvoicesTask → ..."]
TASKS --> PAGES["pages/"]
TASKS --> COMPONENTS["components/"]
TASKS --> SERVICES["services/"]
PAGES --> CORE["core/<br/>base_page · base_task · retry · logger"]
COMPONENTS --> CORE
CORE --> ENGINES["core/engines/<br/>selenium_engine · playwright_engine"]
ENGINES --> BROWSER["Browser → Target website"]
SERVICES --> EXT["Excel · Email · DB · APIs"]
CORE -. logs .-> OTEL["OpenTelemetry Collector<br/>(optional)"]
rpa-<process-name>/
├── AGENTS.md # rules for AI agents (source of truth)
├── CLAUDE.md # points to AGENTS.md
├── README.md
├── .env.example # all env vars, no real values
├── .gitignore
├── requirements.txt
├── pytest.ini # pytest config (import path, e2e marker)
├── main.py # bootstrap: settings, logging, engine → runs the process
├── process.py # the end-to-end process: ordered list of tasks
│
├── config/
│ └── settings.py # reads env vars into a typed settings object
│
├── core/ # technical layer, knows nothing about the business
│ ├── engines/
│ │ ├── base_engine.py # abstract contract every engine implements
│ │ ├── selenium_engine.py
│ │ ├── playwright_engine.py
│ │ └── engine_factory.py # picks the engine from RPA_ENGINE
│ ├── base_page.py
│ ├── base_task.py # retries, timing, logging, screenshots
│ ├── context.py # data shared between tasks
│ ├── retry.py
│ ├── logger.py
│ └── exceptions.py # BusinessException, SystemException
│
├── pages/ # one class per web screen
├── components/ # UI pieces reused across screens
├── tasks/ # steps of the process
├── services/ # Excel, email, DB, APIs (no browser)
│
├── data/
│ ├── input/ # files the bot consumes
│ └── output/ # files the bot produces
├── evidence/ # screenshots
├── logs/
├── tests/ # unit, architecture and e2e tests
├── deploy/
│ ├── azure-pipelines.yml
│ └── airflow_dag_example.py
└── docs/
├── architecture.drawio
├── logo.svg # logo for light backgrounds
└── logo-dark.svg # logo for dark backgrounds
| Folder | What goes here | What never goes here |
|---|---|---|
core/ |
Browser engines, base classes, retries, logging | Business logic, locators of a specific site |
pages/ |
Locators and actions of one screen | Calls to other pages, business decisions |
components/ |
Menus, tables, modals, paginators used on several screens | Screen-specific logic |
tasks/ |
One step of the business process | Locators, direct browser calls |
services/ |
Everything outside the browser | Selenium / Playwright imports |
process.py |
The order of the tasks | Implementation details |
main.py → process.py → tasks/ → pages/ + components/ + services/ → core/
- Dependencies only point down. A lower layer never imports an upper one.
- Tasks never contain locators. They only call methods of pages, components and services.
- Pages never call other pages and never contain business logic. They return data, and tasks decide what to do with it.
- Only
core/engines/importsseleniumorplaywright. - Tasks communicate only through the shared
contextobject, never through globals. process.pydefines the order of the tasks. File names are not numbered.
Everything is in English: code, comments, docs and file names.
| Element | File | Class |
|---|---|---|
| Page | login_page.py |
LoginPage |
| Component | results_table_component.py |
ResultsTableComponent |
| Task | download_invoices_task.py |
DownloadInvoicesTask |
| Service | excel_service.py |
ExcelService |
- Every task exposes a single public method:
run(context) -> context. - Method names are a verb plus an object:
fill_username(),click_login(),get_invoice_total(). - Methods that return a boolean start with
is_,has_orcan_:is_logged_in(),has_results(). - No abbreviations:
start_date, neverst_dt. - Lists are plural and items singular:
for invoice in invoices. - Constants and locators use
UPPER_SNAKE_CASE.
| Prefix | Element | Example |
|---|---|---|
BTN_ |
Button | BTN_LOGIN |
TXT_ |
Text input | TXT_USERNAME |
LBL_ |
Label / text | LBL_ERROR_MESSAGE |
LNK_ |
Link | LNK_FORGOT_PASSWORD |
CHK_ |
Checkbox | CHK_REMEMBER_ME |
RDO_ |
Radio button | RDO_PAYMENT_CASH |
DDL_ |
Dropdown | DDL_DOCUMENT_TYPE |
TBL_ |
Table | TBL_INVOICES |
MDL_ |
Modal / popup | MDL_CONFIRMATION |
IFR_ |
Iframe | IFR_PAYMENT_FORM |
The same locator must work with both engines:
- Locators are plain strings, CSS by default.
- Use XPath only when unavoidable, with the
xpath=prefix:"xpath=//td[text()='Total']/following-sibling::td". - Priority:
id→data-testid→name→ CSS → relative XPath. - Never use absolute XPath (
/html/body/div[3]/...). - Never use
time.sleep(). Always use the engine's explicit waits.
Pages reach the browser only through the protected helpers of BasePage (_fill, _click, _wait_for, ...).
# pages/login_page.py
from core.base_page import BasePage
class LoginPage(BasePage):
URL_PATH = "/login"
TXT_USERNAME = "#username"
TXT_PASSWORD = "#password"
BTN_LOGIN = "button[type='submit']"
LBL_FLASH_MESSAGE = "#flash"
LBL_FLASH_SUCCESS = "#flash.success"
LNK_LOGOUT = "xpath=//a[contains(@href, '/logout')]"
def open_login(self) -> None:
self._open_path(self.URL_PATH)
self._wait_for(self.TXT_USERNAME)
def fill_username(self, username: str) -> None:
self._fill(self.TXT_USERNAME, username)
def fill_password(self, password: str) -> None:
self._fill(self.TXT_PASSWORD, password)
def click_login(self) -> None:
self._click(self.BTN_LOGIN)
def wait_for_flash_message(self) -> None:
self._wait_for(self.LBL_FLASH_MESSAGE)
def is_logged_in(self) -> bool:
return self._is_visible(self.LBL_FLASH_SUCCESS)
def get_flash_message(self) -> str:
return self._get_text(self.LBL_FLASH_MESSAGE).rstrip("×").strip()Tasks implement execute(context). The public run(context) -> context is inherited from BaseTask, which wraps it with retries, timing, logs and screenshots.
# tasks/login_task.py
from core.base_task import BaseTask
from core.context import ProcessContext
from core.exceptions import BusinessException
from pages.login_page import LoginPage
class LoginTask(BaseTask):
STOP_ON_BUSINESS_ERROR = True
def execute(self, context: ProcessContext) -> None:
login_page = LoginPage(self._engine, self._settings.base_url)
login_page.open_login() # always start from a known screen: retries are safe
login_page.fill_username(self._settings.username)
login_page.fill_password(self._settings.password)
login_page.click_login()
login_page.wait_for_flash_message()
if not login_page.is_logged_in():
raise BusinessException(f"Login rejected: {login_page.get_flash_message()}")
context.is_logged_in = True| Item | Convention |
|---|---|
| Env vars | RPA_ prefix (RPA_BASE_URL, RPA_TIMEOUT_SECONDS), except OTEL_* |
| Output files | <process>_<YYYYMMDD_HHMMSS>.<ext> |
| Screenshots | <task>_attempt<n>_<YYYYMMDD_HHMMSS>.png |
| Log files | <process>_<YYYYMMDD>.log |
| Exception | When | Retried? |
|---|---|---|
SystemException |
Timeout, element not found, site down, network error | Yes, up to RPA_MAX_RETRIES (default 3) |
BusinessException |
Invalid data, record not found, business rule failed | No. The error is logged and the process stops, or the task is skipped if STOP_ON_BUSINESS_ERROR = False |
Retrying a technical error makes sense, because the site may just be slow. Retrying "supplier does not exist" three times does not.
On every failed attempt, BaseTask automatically:
- saves a screenshot to
evidence/, - writes a structured log entry,
- retries the task from the start (only for
SystemException). There is no sleep between attempts: the engines' explicit waits handle slow pages.
Any unexpected exception is treated as a SystemException, so it is retried too.
The browser is always closed in a finally block.
Every log entry carries these structured fields:
| Field | Example |
|---|---|
process_name |
sample_login |
task |
login_task |
attempt |
2 |
status |
started · succeeded · failed · retrying |
duration_ms |
1843 |
On failures, entries also carry error_type and screenshot.
The field is
process_nameand notprocessbecauseprocessis a reserved attribute of Python'sLogRecord(the OS process id):loggingrejects it inextra=and OpenTelemetry would drop it.
Logs go to the console and to logs/<process>_<YYYYMMDD>.log:
2026-10-03 06:00:01,120 INFO core.base_task - Task attempt started | process_name=sample_login task=login_task attempt=1 status=started
OpenTelemetry is optional and needs no code changes:
pip install opentelemetry-distro opentelemetry-exporter-otlp
export OTEL_SERVICE_NAME=rpa-download-invoices
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
export OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true
opentelemetry-instrument python main.pyWithout those variables, the bot runs exactly the same, with logs only to the console and files.
- Click Use this template → Create a new repository and name it
rpa-<process-name>. - Clone your new repo:
git clone https://github.com/<your-user>/rpa-<process-name>.git
cd rpa-<process-name>
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
python -m playwright install chromium # only if you use Playwright
cp .env.example .env # fill in your values
python main.pyRun the tests:
pytest # everything; e2e tests run once per engine (headless)
pytest -m "not e2e" # offline: unit + architecture tests, no browserIf an engine library is not installed, its e2e tests are skipped.
RPA_ENGINE=selenium python main.py
RPA_ENGINE=playwright python main.py
# PowerShell
$env:RPA_ENGINE="playwright"; python main.pyReal environment variables override the values in .env.
Pages, components, tasks and services do not change. Only core/engines/ knows which engine is running.
The code only reads environment variables. Where they come from depends on where the bot runs:
| Where | Secrets come from |
|---|---|
| Local | .env file (never committed) |
| Azure DevOps | Variable Groups linked to Azure Key Vault, mapped to env vars in the step's env: block (secrets are not exposed automatically) |
| Airflow | Connections / Variables, passed as env vars by the DAG |
See the examples in deploy/azure-pipelines.yml and deploy/airflow_dag_example.py.
Note: an RPA task (a step of this bot, in
tasks/) is not the same as an Airflow task (a node of a DAG). Usually one Airflow task runs the whole bot (python main.py), which in turn runs all its RPA tasks.
- Create
pages/<screen_name>_page.pywith a<ScreenName>Pageclass that inherits fromBasePage. - Declare the locators as prefixed constants (
BTN_,TXT_, ...). - Add action methods (verb plus object) and query methods (
is_/has_/get_), using only the protected helpers ofBasePage. - If a piece of UI repeats on several screens, move it to
components/.
- Create
tasks/<action>_task.pywith a<Action>Taskclass that inherits fromBaseTask. - Implement
execute(context) -> Noneusing pages, components and services only. Start from a known screen so a retry is safe, and store results as typed fields onProcessContext(core/context.py). - Raise
BusinessExceptionfor business errors. Let technical errors surface asSystemException. - Add the task to
PROCESS_TASKSinprocess.py, in its place in the sequence. - Add any new env var to
.env.exampleandconfig/settings.py. - Add tests in
tests/.
AGENTS.md contains these same rules as short, direct instructions for AI coding agents (Claude Code, Cursor, Copilot, Codex...). CLAUDE.md just points to it.
Keep both files updated when a convention changes. The agent follows what is written, not what the team agreed on verbally.