Most RPA deployments plateau when the underlying UI changes or the logic needs a judgment call. To migrate RPA scripts to AI agents, you replace brittle selectors and hardcoded branches with model-driven loops that read state, decide, act, and verify. This guide gives a step-by-step path we’ve used to move billing and ops automations off fixed scripts and onto agents without a big-bang rewrite, keeping the reliable parts and shedding the fragile ones.
Step 1: Inventory and classify existing RPA scripts
Pull every robot, script, and macro into a single inventory. Tag each with two axes: fragility (how often does it break on UI change?) and judgment (does it require reading unstructured data or making a choice?). A script that downloads a CSV and uploads it unchanged is low judgment; a script that reads invoice PDFs and decides approval is high judgment.
A quick scanner helps. Point this at your RPA repo to count selector-based steps and heuristic branches:
import pathlib, re
def score_script(path: str) -> dict:
text = pathlib.Path(path).read_text()
selectors = len(re.findall(r'(xpath=|css=|\.locator\(|\.find_element)', text))
branches = len(re.findall(r'(if |elif |switch |case )', text))
return {"selectors": selectors, "branches": branches}
inventory = {}
for p in pathlib.Path("./rpa").rglob("*.py"):
inventory[p.name] = score_script(p)
print(inventory)
Any script with high selector count and low judgment is a keep-as-RPA candidate. High judgment items are prime targets to migrate RPA scripts to AI agents. Sort the inventory by judgment score; that’s your migration backlog.
Step 2: Extract action primitives
Don’t throw away the working interaction code. Wrap each UI action as a pure function that takes explicit parameters and returns a status object. The agent will call these as tools. This isolation is what makes the migration safe: the agent can’t accidentally execute a side effect without going through your vetted wrapper.
# old: page.locator("button#submit").click()
# new:
def click_submit() -> dict:
try:
page.locator("button#submit").click(timeout=5000)
return {"ok": True, "element": "submit"}
except Exception as e:
return {"ok": False, "error": str(e)}
def fill_field(selector: str, value: str) -> dict:
try:
page.locator(selector).fill(value)
return {"ok": True, "selector": selector}
except Exception as e:
return {"ok": False, "error": str(e)}
def get_visible_text() -> dict:
return {"ok": True, "text": page.inner_text("body")}
Add a thin logging layer inside each primitive so every agent action is auditable. You now have a stable toolbox independent of the decision logic.
Step 3: Define tools and state schema
Agents need a contract. Define a JSON Schema for the observable state and a list of tools matching the primitives from Step 2. The state should be the minimal snapshot the model needs—not the entire DOM.
{
"state_schema": {
"type": "object",
"properties": {
"current_page": {"type": "string"},
"visible_text": {"type": "string"},
"form_fields": {"type": "array", "items": {"type": "string"}},
"last_error": {"type": "string"}
}
},
"tools": [
{
"name": "click_submit",
"description": "Click the submit button on the current form",
"parameters": {"type": "object", "properties": {}}
},
{
"name": "fill_field",
"description": "Fill a field by CSS selector",
"parameters": {
"type": "object",
"properties": {
"selector": {"type": "string"},
"value": {"type": "string"}
},
"required": ["selector", "value"]
}
},
{
"name": "get_visible_text",
"description": "Return the visible text of the page",
"parameters": {"type": "object", "properties": {}}
}
]
}
Keep the schema tight. Expose only what the model needs to decide. Overly verbose state wastes tokens and confuses the agent.
Step 4: Implement the agent loop
Use an OpenAI-compatible client. The loop sends state, gets a tool call, executes it, feeds the result back. This is the core of how you migrate RPA scripts to AI agents: the script’s linear flow becomes a reactive loop.
import json
from openai import OpenAI
# Point at any OpenAI-compatible gateway. Using n4n.ai gives one endpoint
# for 240+ models with automatic fallback when a provider is degraded.
client = OpenAI(base_url="https://api.n4n.ai/v1", api_key="YOUR_KEY")
tools = [...] # loaded from step 3
def run_agent(initial_state: dict) -> dict:
state = initial_state
for _ in range(10): # max steps
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "Complete the form using tools."},
{"role": "user", "content": json.dumps(state)}
],
tools=tools,
tool_choice="auto"
)
msg = resp.choices[0].message
if not msg.tool_calls:
break
for call in msg.tool_calls:
fn = call.function
if fn.name == "fill_field":
args = json.loads(fn.arguments)
res = fill_field(args["selector"], args["value"])
elif fn.name == "click_submit":
res = click_submit()
elif fn.name == "get_visible_text":
res = get_visible_text()
else:
res = {"ok": False, "error": "unknown tool"}
state["last_error"] = "" if res["ok"] else res["error"]
return state
final = run_agent({"current_page": "login", "visible_text": "", "form_fields": ["user","pass"], "last_error": ""})
The gateway handles provider rate limits; you don’t need custom retry logic. This loop replaces a 200-line script with 30 lines of decision code.
Step 5: Verify each migrated workflow
Verification is non-negotiable. Capture the final state and assert it matches the old RPA outcome on a fixed fixture. Generate fixtures by recording RPA runs on a staging environment.
def test_migrated_login():
fixture = json.load(open("fixtures/login.json"))
agent_state = run_agent(fixture["start_state"])
rpa_state = fixture["expected_end_state"]
assert agent_state["current_page"] == rpa_state["current_page"]
assert agent_state["last_error"] == ""
Run the old RPA script and the agent on the same fixture in CI. Diff the resulting DOM or database row. If they match for 50 consecutive runs, the migration is correct. For visual workflows, screenshot diffing catches regressions the assertions miss.
Step 6: Shadow mode rollout
Deploy the agent alongside the original RPA in read-only or dry-run mode. Log both decisions and any divergence.
def shadow_run():
rpa_result = run_rpa_script()
agent_result = run_agent_from_same_start()
if rpa_result != agent_result:
log_discrepancy({
"rpa": rpa_result,
"agent": agent_result,
"ts": time.time()
})
Review discrepancies daily. Most will be agent being more robust to minor UI tweaks—that’s the win. Some will reveal ambiguous specs in the old script; fix the spec, not the agent.
Step 7: Decommission and monitor cost
Once shadow mode is clean for two weeks, flip the primary path to the agent. Keep the RPA code in repo but disabled behind a flag.
Use per-token usage metering from your gateway to attribute cost per workflow. n4n.ai exposes per-token usage metering so you can query spend by workflow ID:
curl -H "Authorization: Bearer $KEY" \
"https://api.n4n.ai/v1/usage?workflow=login_migration"
If token spend spikes, tighten the state schema or switch to a smaller model. The migration isn’t done until the agent is cheaper or more reliable than the script it replaced.
Verification checklist
- Inventory tagged by fragility/judgment
- All UI actions wrapped as pure functions with logging
- Tool schema validated against agent calls
- Agent loop runs with fallback (no 429 crashes)
- CI test diffs agent vs RPA on fixtures for 50 runs
- Shadow mode ran ≥14 days with <1% unexplained divergence
- Usage metering shows stable per-run cost
Following these steps to migrate RPA scripts to AI agents keeps the deterministic parts and adds judgment where it pays off. You end with systems that survive UI changes instead of breaking on them.