Introduction
If you have maintained a Robotic Process Automation (RPA) pipeline for more than a year, you already know the punchline: it worked beautifully in the demo, and then broke the following Tuesday because someone on the vendor's front-end team renamed a button from "Submit" to "Confirm Order."
Traditional RPA tools β the kind built around recorded macros, fixed XPath expressions, and pixel coordinates β are fundamentally rule-based. They do not understand what a page is trying to do. They only know where something was, last time anyone checked. The moment a website ships a redesign, adds an A/B test, or throws a cookie consent modal that wasn't there yesterday, the automation silently fails or, worse, clicks the wrong thing.
2026 has become the year enterprises stopped patching around this problem and started replacing it outright. The shift toward Agentic AI β systems composed of autonomous, goal-driven agents that can reason, adapt, and self-correct β has moved from research papers into production data pipelines, finance back-offices, and procurement teams. Instead of scripting how to click through a form, teams now describe what they want accomplished, and let a crew of specialized agents figure out the how, in real time, even when the page underneath them changes.
This tutorial walks through building exactly that kind of system: a two-agent crew β a Researcher agent that understands and navigates a target web application, and an Extractor agent that pulls structured data out of it β built with CrewAI for orchestration, Playwright for real browser control, and a Node.js service layer that validates and writes the final structured JSON into PostgreSQL. No fragile CSS selectors hardcoded into a thousand-line script. No "if button not found, wait 5 seconds and try again" hacks.
By the end, you will have a working blueprint you can adapt to your own vendor portals, invoice systems, supplier dashboards, or any legacy web app your organization still depends on.
Why Legacy RPA Breaks (And Keeps Breaking)
Before building the replacement, it is worth being precise about what actually fails in classic RPA, because it explains every architectural decision that follows.
Most RPA tools operate on one of three brittle foundations:
- Absolute selectors β a script that says "click the element at
#submit-btn-42" has no fallback when that ID changes or the element is removed. - Pixel/coordinate automation β automation that clicks at
(482, 310)breaks instantly on any resolution change, zoom level difference, or responsive layout shift. - Fixed step sequences β a script written as "click A, then B, then C" has no concept of an unexpected step D (a promotional pop-up, a session-timeout modal, a cookie banner) inserting itself into the flow.
None of these approaches encode intent. They encode memory of one specific run. That is precisely why RPA maintenance costs have historically eaten up as much budget as the original build β teams spend more time re-recording macros than they spend using the automation they built.
Agentic systems flip this. Instead of memorizing a path, an agent is given a goal ("log in, navigate to the invoices section, and extract every unpaid invoice from the last 30 days") and a set of tools it can use to accomplish that goal (browser navigation, DOM reading, clicking, typing). The agent reasons about the current state of the page on every step and decides what to do next β which means a redesigned button or a surprise modal is just another obstacle to reason around, not a fatal crash.
The Architecture: Researcher + Extractor + Playwright Bridge
Here is the high-level shape of the system we are building:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β CrewAI (Python) β
β β
β ββββββββββββββββββ ββββββββββββββββββ β
β β Researcher ββββββββΆβ Extractor β β
β β Agent β β Agent β β
β β (navigation + β β (structured β β
β β page reasoning)β β data pulling) β β
β βββββββββ¬βββββββββ βββββββββ¬βββββββββ β
β β tool calls β tool calls β
βββββββββββββ΄βββββββββββββββββββββββββ΄βββββββββββββββββββββββββ
βΌ βΌ
βββββββββββββββββββββββββββββββββββββββββββββββ
β Node.js Playwright Bridge (HTTP API) β
β - navigate, click, type, screenshot β
β - readDOM (accessibility tree, not CSS) β
β - popup / modal auto-dismiss detection β
ββββββββββββββββββββββββ¬βββββββββββββββββββββββ
βΌ
Target Web Application
β
βΌ
βββββββββββββββββββββββββββββββββββββββββ
β Node.js Ingestion Service β
β - Zod schema validation β
β - Structured JSON β PostgreSQL β
βββββββββββββββββββββββββββββββββββββββββ
Two design choices matter here:
- CrewAI stays in Python, since it is a Python-native orchestration framework with strong support for role-based agents, task delegation, and memory.
- Playwright runs as a standalone Node.js microservice, exposed over a small internal HTTP API. The agents don't drive the browser directly β they call tools that talk to this service. This keeps the browser automation layer language-appropriate (Playwright's Node API is the most mature and well-documented) while letting CrewAI's Python ecosystem handle the reasoning.
This separation is also what makes the system resilient: the Researcher agent never sees raw HTML or CSS selectors. It sees a semantic snapshot of the page (accessibility tree labels, visible text, interactive element roles) and reasons about that, the same way a human would look at a screen and know "ah, there's a login form here" without caring what the underlying div class names are.
Setting Up the Environment
Start with two project folders: one for the CrewAI orchestration layer, one for the Playwright bridge service.
mkdir agentic-rpa && cd agentic-rpa
mkdir crew-orchestrator playwright-bridge
# Python side
cd crew-orchestrator
python -m venv venv && source venv/bin/activate
pip install crewai crewai-tools langchain-openai python-dotenv requests
# Node.js side
cd ../playwright-bridge
npm init -y
npm install playwright express zod pg dotenv
npx playwright install chromium
Keep your environment variables in .env files on both sides β your LLM provider key for CrewAI, and your PostgreSQL connection string for the Node.js ingestion service. Never hardcode credentials into agent prompts; treat them exactly like you would any other production secret.
Building the Playwright Bridge Service (Node.js)
This service is the "hands" of the operation. It exposes a small set of tool-friendly endpoints that the agents will call. Crucially, instead of exposing raw CSS selectors, it exposes semantic, role-based actions β Playwright's accessibility-first locators (getByRole, getByLabel, getByText) are far more resilient to UI redesigns than #id or .class selectors, because they track what an element means, not where it sits in the DOM tree.
// playwright-bridge/server.js
import express from "express";
import { chromium } from "playwright";
import dotenv from "dotenv";
dotenv.config();
const app = express();
app.use(express.json());
let browser, context, page;
async function ensureSession() {
if (!browser) {
browser = await chromium.launch({ headless: true });
context = await browser.newContext();
page = await context.newPage();
// Auto-dismiss unexpected dialogs, cookie banners, and pop-ups
page.on("dialog", async (dialog) => {
console.log("Auto-dismissing native dialog:", dialog.message());
await dialog.dismiss();
});
}
return page;
}
app.post("/navigate", async (req, res) => {
const p = await ensureSession();
await p.goto(req.body.url, { waitUntil: "networkidle" });
res.json({ ok: true, title: await p.title() });
});
// Returns a semantic snapshot instead of raw HTML
app.get("/snapshot", async (req, res) => {
const p = await ensureSession();
const snapshot = await p.accessibility.snapshot();
const visibleText = await p.evaluate(() => document.body.innerText);
res.json({ snapshot, visibleText: visibleText.slice(0, 6000) });
});
app.post("/click", async (req, res) => {
const p = await ensureSession();
const { role, name } = req.body;
try {
await p.getByRole(role, { name, exact: false }).click({ timeout: 5000 });
res.json({ ok: true });
} catch (err) {
res.status(422).json({ ok: false, reason: err.message });
}
});
app.post("/type", async (req, res) => {
const p = await ensureSession();
const { label, value } = req.body;
await p.getByLabel(label, { exact: false }).fill(value);
res.json({ ok: true });
});
// Autonomous pop-up handler: closes any newly appeared modal/overlay
app.post("/dismiss-popups", async (req, res) => {
const p = await ensureSession();
const candidates = ["Accept", "Close", "No thanks", "Got it", "Dismiss"];
let dismissed = [];
for (const label of candidates) {
const btn = p.getByRole("button", { name: label, exact: false });
if (await btn.count()) {
await btn.first().click({ timeout: 2000 }).catch(() => {});
dismissed.push(label);
}
}
res.json({ ok: true, dismissed });
});
app.listen(4000, () => console.log("Playwright bridge running on :4000"));
Notice the /dismiss-popups endpoint. This is the piece that traditional RPA never had β instead of the whole script dying because a chat widget slid open, the agent can call this tool proactively (or reactively, when a click fails) and keep going.
Building the Researcher Agent
The Researcher's job is navigation and situational awareness. It calls the bridge's /snapshot endpoint, reads the semantic structure of the page, and decides the next action β click, type, scroll, or dismiss a pop-up β based on the stated goal, not a fixed script.
# crew-orchestrator/tools/browser_tools.py
import requests
from crewai_tools import tool
BRIDGE_URL = "http://localhost:4000"
@tool("Navigate to URL")
def navigate(url: str) -> str:
"""Navigate the browser to a given URL."""
r = requests.post(f"{BRIDGE_URL}/navigate", json={"url": url})
return r.json()
@tool("Get page snapshot")
def get_snapshot() -> str:
"""Get a semantic snapshot (accessibility tree + visible text) of the current page."""
r = requests.get(f"{BRIDGE_URL}/snapshot")
return r.json()
@tool("Click element by role and name")
def click_element(role: str, name: str) -> str:
"""Click an interactive element identified by its accessibility role and visible name."""
r = requests.post(f"{BRIDGE_URL}/click", json={"role": role, "name": name})
return r.json()
@tool("Dismiss unexpected pop-ups")
def dismiss_popups() -> str:
"""Attempt to close any cookie banners, modals, or promotional pop-ups on the page."""
r = requests.post(f"{BRIDGE_URL}/dismiss-popups")
return r.json()
# crew-orchestrator/agents.py
from crewai import Agent
from tools.browser_tools import navigate, get_snapshot, click_element, dismiss_popups
researcher = Agent(
role="Web Navigation Researcher",
goal=(
"Navigate the target web application to reach the requested data section, "
"autonomously handling any unexpected pop-ups, cookie banners, or layout "
"changes along the way."
),
backstory=(
"You are an expert at reading unfamiliar web interfaces. You never assume "
"a fixed click path β you read the current page state before every action "
"and adapt accordingly."
),
tools=[navigate, get_snapshot, click_element, dismiss_popups],
verbose=True,
allow_delegation=False,
)
Building the Extractor Agent
Once the Researcher has landed on the correct page, the Extractor agent takes over. Its job is narrower and more disciplined: read the visible content and return only validated, structured JSON β nothing else, no commentary, no markdown wrapping.
# crew-orchestrator/agents.py (continued)
from crewai import Agent
from tools.browser_tools import get_snapshot
extractor = Agent(
role="Structured Data Extractor",
goal=(
"Extract the requested fields from the current page's visible content and "
"return strictly valid JSON matching the required schema. Never invent values."
),
backstory=(
"You are meticulous and literal. If a field is not visible on the page, "
"you return null for it rather than guessing."
),
tools=[get_snapshot],
verbose=True,
allow_delegation=False,
)
That last instruction β "never invent values" β matters enormously in production. LLM-based extractors will happily hallucinate a plausible-looking invoice number if you don't explicitly forbid it. Pair this instruction with a strict downstream schema validator, which we'll build next, so hallucinated data never silently reaches your database.
Orchestrating the Crew
With both agents defined, wire them into tasks and a Crew:
# crew-orchestrator/main.py
from crewai import Crew, Task, Process
from agents import researcher, extractor
navigate_task = Task(
description=(
"Go to https://vendor-portal.example.com/login, log in using the "
"provided credentials, navigate to the Invoices section, and filter "
"for unpaid invoices from the last 30 days. Handle any pop-ups that "
"appear along the way."
),
expected_output="Confirmation that the unpaid invoices list is visible on screen.",
agent=researcher,
)
extract_task = Task(
description=(
"From the current page, extract every visible invoice as JSON objects "
"with fields: invoice_id, vendor_name, amount_due, due_date, status. "
"Return a JSON array only."
),
expected_output="A valid JSON array of invoice objects.",
agent=extractor,
context=[navigate_task],
)
crew = Crew(
agents=[researcher, extractor],
tasks=[navigate_task, extract_task],
process=Process.sequential,
memory=True,
)
result = crew.kickoff()
print(result)
Running python main.py kicks off the sequence: the Researcher reasons its way through login and navigation (calling dismiss_popups whenever a click fails or a snapshot shows an unexpected overlay), then hands control to the Extractor, which reads the final page state and emits structured JSON.
Handling Layout Drift and Unexpected Pop-ups Autonomously
This is the core value proposition, so it deserves a concrete walkthrough. Suppose the vendor portal ships an update overnight that adds a "We've updated our terms" modal right after login. A classic RPA script would:
- Attempt to click the next scripted button.
- Fail, because the modal is now the topmost element intercepting clicks.
- Time out or throw an unhandled exception.
- Alert an on-call engineer at 2 a.m.
In our agentic system, the flow looks different:
- The Researcher agent calls
/clickfor the expected next button. - Playwright returns a
422because the click target is obscured or not found. - The agent's reasoning loop interprets this failure, calls
/snapshotto re-read the current page state, notices an unfamiliar modal in the accessibility tree, and callsdismiss_popups. - It then retries the original click, which now succeeds because the modal is gone.
No human intervention, no code change, no redeployment. The same self-correcting loop handles renamed buttons too β because the agent locates elements by role and visible name (e.g., "button named something like Submit") rather than by a hardcoded selector, a rename from "Submit" to "Confirm Order" is something the agent can reason about directly from the page's visible text, especially when you give it a fallback instruction like "if the exact label isn't found, look for a button that appears to submit or confirm the form."
Validating and Piping JSON into PostgreSQL
The final and most safety-critical step: never trust raw LLM output directly against your database. Route everything through a schema validator first.
// ingestion-service/schema.js
import { z } from "zod";
export const InvoiceSchema = z.object({
invoice_id: z.string().min(1),
vendor_name: z.string().min(1),
amount_due: z.number().nonnegative(),
due_date: z.string().refine((d) => !isNaN(Date.parse(d)), {
message: "Invalid date",
}),
status: z.enum(["unpaid", "overdue", "pending", "paid"]),
});
export const InvoiceArraySchema = z.array(InvoiceSchema);
// ingestion-service/ingest.js
import pkg from "pg";
import { InvoiceArraySchema } from "./schema.js";
const { Pool } = pkg;
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export async function ingestInvoices(rawJson) {
const parsed = InvoiceArraySchema.safeParse(rawJson);
if (!parsed.success) {
console.error("Validation failed:", parsed.error.flatten());
throw new Error("Extractor output failed schema validation. Aborting write.");
}
const client = await pool.connect();
try {
await client.query("BEGIN");
for (const invoice of parsed.data) {
await client.query(
`INSERT INTO invoices (invoice_id, vendor_name, amount_due, due_date, status)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (invoice_id) DO UPDATE
SET amount_due = EXCLUDED.amount_due, status = EXCLUDED.status`,
[invoice.invoice_id, invoice.vendor_name, invoice.amount_due, invoice.due_date, invoice.status]
);
}
await client.query("COMMIT");
console.log(`Inserted/updated ${parsed.data.length} invoices.`);
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
The ON CONFLICT ... DO UPDATE clause makes this idempotent β re-running the crew doesn't create duplicate rows, it just refreshes the ones that changed. That matters because agentic pipelines are typically run on a schedule (hourly, nightly) rather than once, and idempotency is what keeps your reporting layer trustworthy.
Real-World Example: Automating a Vendor Invoice Portal
Picture a mid-size logistics company that receives invoices through eleven different carrier portals, none of which share a common layout, and three of which redesign their dashboards at least once a year. Under the old RPA setup, each portal needed its own dedicated script, and roughly 20% of runs failed monthly due to UI drift, consuming several engineer-hours per week just on selector repair.
Replacing this with the Researcher/Extractor crew described above means:
- One generic Researcher agent handles login and navigation across all eleven portals, guided only by a per-portal goal description ("navigate to Billing, then Statements") rather than a per-portal script.
- One generic Extractor agent pulls the same five fields regardless of how each portal visually presents them.
- The same PostgreSQL ingestion layer validates and stores every portal's output identically.
The result is not zero maintenance β schemas still need occasional updates when a carrier adds a genuinely new field β but the maintenance burden shifts from "rewrite selectors every time a button moves" to "occasionally update a goal description in plain English."
Best Practices
- Give agents semantic tools, not raw DOM access. Expose
getByRole/getByLabelstyle actions from your bridge service rather than letting agents write arbitrary selectors. - Keep the Extractor agent's temperature low. Extraction should be deterministic and literal β creativity here produces hallucinated fields, not useful variety.
- Always validate before writing. A schema validator (Zod, Pydantic, or similar) between the agent output and your database is non-negotiable.
- Log every tool call. Store the sequence of navigate/click/type/dismiss actions per run β this becomes your audit trail and your debugging tool when a run behaves unexpectedly.
- Use idempotent writes.
UPSERTpatterns protect you from duplicate rows when a scheduled run retries after a partial failure. - Cap agent retries. Give the Researcher a maximum number of self-correction attempts per task so a truly broken portal fails loudly instead of looping indefinitely and burning LLM tokens.
Common Mistakes
- Letting the Extractor agent browse and extract in one step. Splitting navigation and extraction into separate agents with separate responsibilities produces far more reliable output than one agent trying to do both.
- Skipping schema validation "because the LLM is usually right." Usually is not always, and a single hallucinated
amount_duevalue in a financial pipeline can cause real damage. - Hardcoding credentials or API keys into agent prompts or task descriptions. Treat these exactly as you would in any other backend service β environment variables, secret managers, never plain text in a prompt.
- Ignoring rate limits and politeness. An autonomous agent can click a lot faster than a human. Add deliberate delays and respect the target site's terms of service and
robots.txtguidance. - Assuming zero maintenance. Agentic systems reduce selector-related breakage dramatically, but genuinely new page sections or workflows still need a goal-description update.
π Pro Tips
- Run the Researcher agent with a smaller, cheaper model for routine navigation and reserve a stronger reasoning model only for the Extractor or for escalation cases where navigation repeatedly fails.
- Add a confidence field to your Extractor's output schema and route anything below a threshold to a human review queue instead of straight into PostgreSQL.
- Snapshot the accessibility tree before and after every click during development β diffing the two is the fastest way to debug why an agent took an unexpected action.
- Use Playwright's built-in trace viewer (
context.tracing.start/stop) during testing so you can visually replay exactly what the agent's browser session did. - Version your goal descriptions like you would version code β a small wording change in a task description can meaningfully change agent behavior.
π Key Takeaways
- Legacy RPA breaks because it encodes memory of one run instead of intent β selectors, coordinates, and fixed sequences all fail the moment a UI changes.
- A Researcher/Extractor multi-agent split, orchestrated with CrewAI, separates "figure out where to go" from "pull the data out," which makes each agent's job simpler and more reliable.
- Playwright's role-based locators, wrapped as agent tools, let the system survive redesigns and unexpected pop-ups without a single hardcoded CSS selector.
- Schema validation with Zod (or an equivalent) before any PostgreSQL write is what makes this architecture safe enough for real production data.
Conclusion
The move from legacy RPA to agentic, multi-agent automation is not a hype cycle β it is a direct response to the maintenance costs that rule-based automation has quietly accumulated for over a decade. By separating navigation reasoning (Researcher) from data extraction (Extractor), grounding both in semantic, role-based Playwright tools instead of brittle selectors, and enforcing strict schema validation before anything reaches PostgreSQL, you get an automation pipeline that bends instead of breaking when the web changes underneath it.
Start small: pick one flaky RPA script you already maintain, rebuild it as a two-agent crew following the pattern above, and compare failure rates over a month. Most teams find the difference immediately obvious β and once you've felt an agent quietly dismiss a pop-up and keep going instead of paging you at 2 a.m., it is hard to go back.
References
- CrewAI official documentation β docs.crewai.com
- Playwright official documentation β playwright.dev
- Zod schema validation library β zod.dev
- node-postgres (
pg) documentation β node-postgres.com - W3C Web Accessibility Initiative, Accessible Rich Internet Applications (ARIA) β w3.org/WAI/ARIA