Robotic Process Automation is dead. Give the job to an agent.

An RPA bot follows steps someone decided in advance, and every new job means building another bot. An agent is given a goal, decides each step while it runs, and takes on a new job with a new prompt. We rebuilt a real RPA workflow as an agent on Flyte 2, broke it on purpose, and recorded every decision it made.

An RPA bot follows steps someone decided in advance, and every new job means building another bot. An agent is given a goal, decides each step while it runs, and takes on a new job with a new prompt. We rebuilt a real RPA workflow as an agent on Flyte 2, broke it on purpose, and recorded every decision it made.

Why RPA is dead

We gave an agent an RPA job and broke it on purpose by redesigning the supplier's portal overnight. It finished anyway.

RPA made sense before AI could read a screen. An RPA bot follows a process someone fixed in advance. That works until it doesn't: when a supplier renames a button, a pop-up appears, or an API starts refusing a call, the bot fails.

An agent works more like a person. Give it a goal, and it changes its plan as it runs. A new job is just a new prompt. So you get everything a bot could do, without its two biggest pain points: fixing bots when the world changes, and building new ones when the work does.

Don't believe us? See it in action.

Watch an agent do a bot's job

The job

The job is a typical distributor workflow. Every morning, purchasing brings the ERP's prices in line with the supplier's latest price list:

The supplier portal and the "Acme ERP" desktop client are stand-ins we built for the demo. They run on a Windows Server 2025 VM, the kind of machine RPA bots already run on. The workflow runs in Union on a cron trigger at 06:00 Central.

The twist: overnight, the supplier redesigned its portal. The export button moved into a Reports menu under a new label, and a cookie banner now covers the page. A recorded-click bot fails at this point. (For the demo we switch the redesign on deliberately, with a `chaos` flag.)

What the agent did

The agent got one sentence, "Run the daily supplier price update for 2026-11-06", plus its standing instructions. Everything below is what it chose to do. Times are measured from the start of the run:

Time Step Tool Result
0:38 Scripted portal download Browser (Playwright) Failed after 18 s: `#export-btn` not found. A screenshot of the page is attached to the step.
0:56 List SKUs and prices ERP API Current ERP prices
1:05 Check VM health System state All app services running
1:33 Look for the file in Downloads Files Empty
1:46 Switch to the screen: log in to the portal and download today's price list CSV to Downloads, changing nothing else Computer use Done in 75 s (7 model turns). Signed in, dismissed the cookie banner, found "Download supplier pricing" under Reports. Recorded.
3:02 Switch back: confirm the CSV landed, copy it into the run Files `prices_2026-11-06.csv`
3:22 Build the change workbook Files 8 of 25 SKUs changed
3:42 Write the workbook to the share, run the staging script Files + shell 8 rows validated and staged
4:03 Try the first price update through the API ERP API 403: "Price overrides are only accepted through the Acme ERP desktop client (Price Maintenance screen), policy ERP-114."
4:15 Switch to the screen: enter the 8 SKUs and their exact new prices one by one in Price Maintenance Computer use Done in 135 s (24 model turns). Confirmed three changes over 10% in the ERP's own dialog and recovered from two session-timeout pop-ups. Recorded.
6:33 Verify ERP API All 8 prices correct; the audit log has exactly 8 entries for today, all from the desktop client
The page the scripted download broke on: a cookie banner over the dashboard, and the export button gone into a Reports menuType image caption here (optional)

That screenshot is what the Playwright step saw when it timed out, attached to the failed node in the run. This is where an RPA bot stops. Here is what the agent did with the same page a minute later:

Computer use signs in to the redesigned portal, accepts the cookie banner, and downloads the price file from the Reports menu (2x speed)

Three moments in the run are branches nobody wrote. An RPA bot would have needed each one predicted, coded and tested in advance.

The redesign. Nobody told the agent the portal had changed. It read the Playwright error, decided the page no longer matched the script, and handed that one step to computer use with a precise goal. As soon as the file was downloaded, it went back to file tools to collect it, rather than asking the screen to confirm.

The policy refusal. The API's 403 explained that price overrides only go through the desktop client. The agent didn't retry the other seven SKUs against the API. It read the refusal as a rule about the whole batch and moved all eight to the desktop in a single session.

The session timeout. Partway through entering prices, the ERP showed a session-timeout pop-up while the agent was moving from one SKU to the next. When the agent dismissed it, the SKU it had typed hadn't loaded, and the price field still held the previous SKU's price. It noticed, reloaded the SKU, and checked that nothing wrong had been saved. For every remaining SKU, it confirmed the item had loaded before pressing Save. The ERP's audit log shows exactly one entry per SKU.

The ERP session times out mid-entry; computer use dismisses the pop-up, reloads the SKU and saves the right price (1.5x speed)

When it finished, the agent reported what a person might want to know: three prices moved more than 10%, so a buyer may want to check them, and the old scripted download no longer works on the new portal. Nobody has to fix that before tomorrow.

The same agent, the next job

The second cost of RPA, a new bot for every new job, goes away for the same reason. Here is the entire task-specific part of the agent's instructions:

Copied to clipboard!
## The job

1. Get today's supplier price list (CSV) from the BuildSupply supplier portal.
2. Work out which ERP prices differ from the supplier's, as a price_changes workbook.
3. Put the workbook on the VM share and stage it with the VM's import script.
4. Enter each changed price in the ERP.
5. Verify the ERP now shows the new prices, and report what you did.

Five lines, written the way you'd brief a new hire.

Most of the tools are reusable too. About 10 of the agent's 15 tools work on a system, not on this job: run a command on the VM, read or write a file, restart a service, look up SKUs in the ERP, operate the desktop, ask a person to approve. Any job on these systems can use them. Only a few are specific to price updates, like the one that builds the change workbook.

So the cost model changes. You build tools once per system: the ERP, the portal, the VM. Every task reuses them. A new job is a new prompt, plus the occasional new tool:

Copied to clipboard!
await agent.run.aio(
    "Compare the ERP's current prices with today's supplier file and write a report "
    "of every difference to the share. Don't change anything in the ERP."
)

With RPA, that reconciliation job is another bot: another recording, another set of selectors, another thing that breaks when the portal changes. With an agent, it's another sentence, and one that anyone in purchasing could write. Developers own the tools and the guardrails, like the approval gate below; the people who own the process write the jobs.

Want to do it too? The demo, explained

The full example is in the agentic RPA tutorial in our workshops repo. It runs on your own machine against a Linux desktop in Docker, with the same toy portal and ERP and the same tools: start the Docker desktop, run the agent with `--chaos`, and watch it work around the redesign. The rest of this section walks through how it's built on Flyte 2.

Every tool is a task

Each capability is an ordinary async Python function decorated with `@env.task`. Here are the ERP API tool and the Playwright download. The download is the old bot, wrapped as a tool; that's how existing bots carry over:

Copied to clipboard!
@env.task
async def erp_set_price_via_api(sku: str, new_price: float) -> dict:
    """Try to update a SKU's price through the ERP REST API. Returns the API's answer, including refusals."""
    async with _erp() as c:
        r = await c.post(f"/skus/{sku}/price", json={"price": new_price})
        return {"ok": r.is_success, "status": r.status_code, "detail": r.json().get("detail", r.text)}


@browser_env.task(report=True)
async def portal_download_prices(run_date: str = "") -> File:
    """Log into the supplier portal with Playwright and export today's price list CSV.
    Fails with the selector that broke if the portal's layout has changed."""
    ...
        except PwTimeout as e:
            shot = base64.b64encode(await page.screenshot()).decode()
            flyte.report.get_tab("Browser").log(f"

Failed at: {html.escape(step)}

... ") await flyte.report.flush.aio() raise RuntimeError( f"Portal automation failed at step '{step}': the page no longer matches the script " f"(likely a portal redesign). Playwright: {str(e).splitlines()[0]}" ) from None

Two design choices matter here, because an agent decides from what its tools tell it. `erp_set_price_via_api` returns the refusal instead of raising, so the agent can read the reason. `portal_download_prices` raises an error written for a reader, either a person or a model, that names the step that broke and attaches a screenshot of the page to the run's report.

Each family of tools runs in its own container. The browser tools get an image with Chromium in it, the computer-use loop gets its own long-lived environment, and the orchestrator and the API, shell and file tools share a light image of about 1 GiB. Nothing pulls a browser into a pod that only makes REST calls.

The agent prefers the cheap tools

Operating a screen is the slowest and most expensive thing the agent can do, so its instructions rank the tools by cost and reliability, and tell it to use the lowest one that can do the step:

Rank Mechanism Example in the demo Cost / reliability
1APIList active SKUs and prices from the ERP's REST APICheap, deterministic
2System hooksCheck services and processes on the VMCheap
3Scripts and shellRun the import-staging script over SSH (PowerShell or bash)Cheap, deterministic
4FilesRead the supplier CSV, build the change workbook, write it to the shareCheap, deterministic
5Browser (DOM)Playwright signs in to the supplier portal and exports the price fileMedium; breaks when the DOM changes
6Computer useA model operates the desktop with screenshots, mouse and keyboardExpensive and slow, but adapts to what it sees; recorded

There's no routing code. The ranking and the rules for moving between tools are a few lines of the instructions:

Copied to clipboard!
Move up the ladder only when a lower rung has failed or cannot do the step, and say why when you do.
`computer_use` is slow and expensive: give it one precise, self-contained goal (exact SKUs and values,
where files should end up, how to confirm success), then continue with cheaper tools.

- A refusal that is about policy or capability (for example "only accepted through the desktop client")
  applies to every item, not just the one you tried: do not repeat the same call for the other items,
  move up the ladder for the whole batch.
- Never invent prices. Every new price must come from the supplier file for the run date.

In this run, only 2 of the 15 calls used the screen. They took 3.5 minutes; the other 13 calls took 2.1 minutes combined.

The 7-minute run to scale, coloured by tool: two computer-use blocks of 75 s and 135 s take more time than the other 13 calls combined

The agent isn't a model clicking through a desktop all day; it's a model choosing between an API call and a click, and choosing the API whenever it can. If it uses the screen for the same step every day, you can add an API or a script for that step, and the agent starts using it, with no change to the prompt.

The agent and its schedule

The agent itself is a few lines with `flyte.ai.agents.Agent`, and a cron trigger starts it every morning:

Copied to clipboard!
agent = Agent(
    name="price-update",
    instructions=INSTRUCTIONS,
    model=config.ORCHESTRATOR_MODEL,
    tools=[
        tools.erp_list_skus, tools.erp_set_price_via_api, tools.erp_audit,
        tools.vm_system_state, tools.vm_restart_service,
        tools.vm_shell, tools.vm_stage_import,
        tools.vm_list_files, tools.vm_read_file, tools.vm_write_file,
        tools.build_price_changes, tools.read_price_changes,
        tools.portal_download_prices,
        tools.computer_use,
        tools.request_approval,
    ],
    max_turns=40,
    # Steps have side effects on one desktop; run them in the order the agent chose.
    parallel_tool_calls=False,
)

daily = flyte.Trigger(
    "daily-price-update",
    flyte.Cron("0 6 * * *", timezone="America/Chicago"),
    inputs={"trigger_time": flyte.TriggerTime, "chaos": False},
)

@env.task(triggers=daily, report=True)
async def price_update(trigger_time: datetime | None = None, chaos: bool = False) -> dict:
    run_date = (trigger_time.date() if trigger_time else date.today()).isoformat()
    await tools.set_portal_chaos(chaos)
    result = await agent.run.aio(f"Run the daily supplier price update for {run_date}.")
    if result.error:
        # Fail the run, so a broken bot shows up as a failed run rather than a green one.
        raise RuntimeError(f"price update for {run_date} failed after {result.attempts} turns: {result.error}")
    return {"run_date": run_date, "summary": result.summary, "turns": result.attempts}

Nothing about the graph is declared in advance. Flyte 2 tasks are plain async Python, so every tool call the agent makes becomes a child action as it happens. The run graph is the record of what the agent decided, including the failed Playwright attempt and the API's 403.

The run in Union: 15 tool calls in the order the agent made them, the failed Playwright step in red, and the two computer_use nodes holding 6 and 23 traced desktop steps

Computer use is a task, and it records the screen

Operating the desktop is one task with a narrow interface: a goal goes in, and a result plus a recording come out.

Copied to clipboard!
@cu_env.task(report=True)
async def computer_use(goal: str, max_steps: int = 40) -> ComputerUseResult:
    """Operate the VM's desktop like a person (screenshots, mouse, keyboard) to accomplish `goal`.
    Slow and expensive: use only when no API, script, file or DOM-based tool can do the step.
    The session is screen-recorded; the mp4 is attached to the result."""
    rec = cu.Recorder(cu.new_session_name())
    await rec.start()
    try:
        res = await cu.run_loop(goal, max_steps=max_steps)
    finally:
        local = await rec.stop()
    recording = await File.from_local(local) if local else None
    ...
    return ComputerUseResult(res.status, res.summary, res.steps, res.actions, recording)

The loop inside it uses Claude's computer-use toolset, and the actions are carried out on the VM by a small computer server. Each model turn's batch of clicks and keystrokes is wrapped in a `flyte.trace` call, so every turn is its own node, with its inputs and outputs:

Copied to clipboard!
@flyte.trace
async def computer_step(step: int, actions: list[dict]) -> list[dict]:
    """One model turn's batch of desktop actions. Returns per-action outcome (images elided)."""
    return actions

That gives two layers of evidence. The "Computer use" report tab streams a timeline as the session runs, showing for each step a screenshot, the model's reasoning, and the actions it took. The full recording is an mp4 attached to the task as a `flyte.io.File` output, so it sits in lineage next to the goal that produced it. The recorder stops in a `finally` block, so a failed session keeps its recording. A failed session is exactly when you want the recording.

Here is that report from this run's second computer-use session, the one that entered the 8 prices: every step's screenshot, the model's reasoning and its actions, plus the recording.

Guardrails are code

An agent can be made as predictable as you need: hard limits on what it can do, a second check on what it did, and a full record of every decision.

Hard limits are tools the agent can't skip. The approval gate is a tool backed by an external condition, and the run blocks until a person answers:

Copied to clipboard!
@env.task
async def request_approval(summary: str) -> bool:
    """Pause the run until a person approves or rejects. Use before committing a large batch of changes."""
    cond = await flyte.new_condition.aio(
        "approve-price-changes", prompt=summary, prompt_type="markdown", data_type=bool,
    )
    return bool(await cond.wait.aio())

Above 10 changes, the agent must call it before entering anything. Today's run had 8 changes, so it didn't pause. Prices can only come from the supplier file, and the agent can only reach the systems its tools expose. Credentials belong in the same place: a sign-in tool that types the secret itself, so the model never sees it. (The tutorial keeps the portal password in the instructions for simplicity.)

Every run is checked independently. The run ends with a check through the ERP's own API and audit log, rather than trusting the agent's word that it finished. In this run: 8 prices correct, exactly 8 audit entries, nothing else changed.

Every decision is on the record. A bot's log tells you which click failed. The run graph tells you what the agent saw, what it decided, and why, with a screenshot of every desktop step and a recording of every session. When something goes wrong, you can read the reasoning without rerunning anything. That's more of an audit trail than a bot gives you, not less.

To build your own, start with building an agent on Flyte 2, then traces and reports for the record of every decision, and external conditions for approval gates.

Conclusion

An RPA bot follows a process someone fixed in advance, and that works until it doesn't. Our agent was given a goal and changed its plan as it ran, around a portal redesign, an API refusal and a session timeout that nobody planned for. Its next job is just a new prompt. Stop building bots.

View full code on GitHub →
Sign up

30 day free trial

Try the devbox
No items found.