All projects

Case study · Cabify Spain · Sep 2026

RPA Uploader · Mac mini

The internal RPA tool runs bulk changes from a CSV: bonuses, licences, vehicle products, attachments. Uploading meant opening the right panel, attaching the file, waiting and checking the result by hand. Now you drop the file in a Drive folder. A service on the team's Mac mini works out which RPA it belongs to, uploads it, waits for the real outcome and posts it to Slack with every link.

60 → 13 min per big filelocal automation
PythonPythonPlaywrightPlaywrightMac mini (macOS)Mac mini (macOS)Google DriveGoogle DriveSlackSlackBashBash
My roleDesigned, built and operate it. The documentation is written so anyone on the team can use it.
WhenSep 2026 · validated on real files from 3 Sep
Built withPython, Playwright, launchd, Google Drive for desktop, Slack, on a company Mac mini
StatusIn production. It is also the last step of ADP.
33
RPA products recognised by their columns, not by the file name
60 → 13 min
for a 380 KB file, split into five parallel parts
1 s
per row: the RPA's pace, measured on a week of real runs
0
silent failures: every path posts a message
Lives on the Mac mini. The uploader shares the machine with my local AI agent; the mascot is that agent's face and marks every project in this portfolio that runs there. The Mac mini is company equipment, approved for this use.
The local AI agent mascot: a small robot with a beret and a scarf

How the team uses it

Three steps, and two of them are waiting. Drop a real .csv in the folder; wait about a minute, since the Mac mini checks every 60 seconds; read Slack, where two messages arrive: one when the file is accepted and one with the real result.

CSV dropped inDriveanyone on theteamDrive syncs tothe Mac minisecondslaunchd wakesthe watcherevery 60 secondsProduct by itscolumns33 signatures,similarity above0.6Size andattachmentssplit, or pairwith its ZIPUpload withPlaywrightthe RPA has noAPIWait for thereal resultminutes or hours,without blockingSlack withevery linkand a cc to theowner
A file's journey, with nobody touching it. No n8n, no tunnels, no public endpoint: everything is an outgoing connection from the Mac mini.
# rpa-uploads
RPA
RPA uploaderAPP16:30
SENT · Create Driver Bonus · cc @owner
File: bonus_adhoc_august.csv · status: running. I'll post the real result when it finishes.
RPA
RPA uploaderAPP16:34
RESULT OK · Create Driver Bonus · Successful, no failed rows.
RPA
RPA uploaderAPP09:07
RESULT WITH FAILURES · Add Remove Asset Products
8 of 3,584 rows failed: motor_type can't be blank (×7) · asset not found (×1)
RPA
RPA uploaderAPP09:06
WAITING FOR THE ZIP · Upload Asset Attachments
The CSV lists 3 documents; docs.zip is missing 2. Nothing has been sent.
RPA
RPA uploaderAPP09:00
Daily update · folder: 15 CSV · Drive: syncing · last 24 h: 3 sent (2 OK, 1 with failures) · runs being watched: 0
What the team sees in Slack, with invented file names. The result message counts failed rows and groups them by reason: 8 of 3,584 and 8 of 13 are very different problems.

Recognising the product by its columns

Each RPA product expects specific columns, its signature. The watcher normalises the header of the CSV, compares it with the 33 signatures and keeps the best match above 0.6. Real files are called things like "entries (3).csv", so the file name is useless; a file it cannot place is not sent. The first time a new type goes through, someone checks that first upload before trusting the automation.

# Which RPA does this file belong to? Decide by content, never by name.
def detect_product(header, signatures, min_score=0.6):
    cols = {normalise(c) for c in header}
    best, score = None, 0.0
    for product, expected in signatures.items():
        s = len(cols & expected) / len(cols | expected)   # Jaccard similarity
        if s > score:
            best, score = product, s
    return (best, score) if score > min_score else (None, score)

def plan_parts(size_kb):
    if size_kb <= 20:  return 1                 # about 5 minutes: not worth splitting
    if size_kb <= 495: return 5                 # five equal parts, all at once
    return math.ceil(size_kb / 99)             # 99 KB parts, in batches of five
A file appearsIs it a real.csv?no: "not processable,export as CSV"Do the columnsmatch a product?no: "product notrecognised", nothingsentDoes it need aZIP, and is itcomplete?no: waits, lists themissing documentsIs it big?yes: split into partsIs there an RPAsession?no: queued, sentafter the next loginSent · message 1then the real result· message 2
No path ends in silence. The exits that stop a file also post a message that says why.

Splitting: why and how

The RPA accepts much larger files. The problem is time, not size: it processes about one row per second, with remarkable regularity. I measured it over a week of real runs rather than guessing.

0 min20 min40 min60 min80 min0 rows1,000 rows2,000 rows3,000 rows4,000 rows5,000 rowsDashed line: exactly 1 second per row54 rows · 64 s (1.19 s per row)127 rows · 128 s (1.01 s per row)3,584 rows · 3,632 s (1.01 s per row)4,764 rows · 4,520 s (0.95 s per row)54 and 127 rows: 1.19 and 1.01 s/row3,584 rows: 1.01 s/row4,764 rows: 0.95 s/row
Measured run time against rows: 54 rows took 64 seconds and 4,764 rows took 75 minutes. The dashed line is exactly one second per row.

That gives the rule. Up to 20 KB a file goes whole, because it takes about five minutes anyway. From 20 to 495 KB it is split into five equal parts that run at the same time. Above that, 99 KB parts go in batches of five, and the next batch starts when the previous five finish. There is one Slack message at the end, not one per part.

380 KB file, sent whole380 KB file, sent whole: 60 min60 minSame file in 5 parallel partsSame file in 5 parallel parts: 13 min13 min
The same 380 KB file: an hour when sent whole, about 13 minutes in five parallel parts.
The split never rewrites a row. It cuts on line breaks and copies the original bytes. Rewriting rows is exactly what once introduced two typos into a payroll file, and it will not happen here. If a CSV has line breaks inside quoted fields, where cutting by lines would break it, the file is sent whole instead.

Products that need two files

Attachment uploads need a CSV and a ZIP with the documents. The watcher pairs them by content: the CSV's file column names each document, and the ZIP that contains all of them wins. Pairing by name would have failed on day one, and pairing by upload time would cross the files of two people working at once.

SituationWhat the system does
Only the CSV has arrivedWaits and posts after 2 minutes. Upload order does not matter.
Only the ZIP has arrivedWaits quietly; the daily update lists it as a ZIP with no CSV.
The ZIP is incompleteNames exactly which documents are missing, and sends nothing.
The pair is completeSends both at once, each to its own field.

When the session drops at night

The RPA has no API; it is reached through a browser session that a person renews every morning. If a file arrives at 03:12, the watcher posts one message saying there is no session, not one a minute. Between 20:00 and 08:00 it opens no windows and stays quiet. At 09:05 someone logs in and the queued file goes out on its own. Nothing is lost.

How you know it is still alive

An automation that fails silently is worse than none. Each of these alerts exists because the failure it covers already happened once:

AlertThe failure it covers
The watched folder does not existOn 4 September the folder was renamed and the service stood still for three days without anyone noticing.
Drive is not runningThe folder is still there but frozen: nothing arrives and it looks like there is no work.
I cannot read the fileDrive had not downloaded it yet. It retries and posts on the third attempt.
File not processableAn .xlsx or a native Sheet that would never have been sent, silently.
Daily update at 09:00Any failure nobody predicted: if the message does not arrive one day, something is wrong.

A macOS detail took the longest to find: background processes start with on-demand downloads switched off, so files Drive had not downloaded were unreadable to the service. The fix was a small launcher with the right permissions and a policy the watcher re-enables at start. The old workaround, marking the folder as available offline, is no longer needed. The log also limits itself: above 5 MB it is archived and a new one starts.

What I learned

  • Measure the slow system before designing around it. The one-second-per-row pace made the splitting rule obvious.
  • With payment files, never touch the content. Use the original bytes or do not send it.

Internal tool names, hosts, people and file contents are left out on purpose. The Slack messages above use invented file names.