On June 7 one of my workflows passed every check I had for it, including the manual one where I look at the output myself. It reads product pages and writes the spreadsheet a marketplace wants for a bulk upload. It did that with seven tools, one per marketplace, each building that marketplace's format from a column mapping I kept by hand. On June 11 I marked those spreadsheets unusable in practice and pulled the workflow back to an experimental label. They followed my own idea of each marketplace's layout, and a real seller center wouldn't take them.
By June 13 the seven tools were deleted. The workflow now asks the seller for the marketplace's own upload template, the actual file they'd upload, and uses two tools that know nothing about any marketplace: one reads a spreadsheet, one writes rows into a copy of it. Deciding which product field goes in which column is now the agent's job.
That's the trade. A tool written for one format can't put the price in the wrong column; an agent can. In return the workflow handles templates I've never seen, and a changed template isn't a code change anymore. What made it work on a real file comes down to one idea: if the agent makes the decisions, the tools have to show it enough to make them. In practice that meant tools that hand back what they see plus, at most, a labelled suggestion, and a prompt that says what to do with every warning a tool can return. Mine returned the right warning once, and the agent saved the file anyway.
What the agent couldn't see
The two tools are inspect_xlsx_template, which reports what's in the template, and fill_xlsx_template, which writes rows into a copy and keeps everything else (header rows, dropdowns, formatting). That same day I wrote down the rule that decides what counts as a fix from here on: tools are generic capabilities, anything specific to a scenario goes in the agent's instructions, and a run that passes because a tool was special-cased counts as a failed run.
The tools were built against synthetic templates and worked. The first real seller-center template failed three times before the agent had mapped a single column, and each failure was something the agent couldn't see.
It couldn't see the file at all. The template declares its frozen pane as activePane="bottom_left", where the spreadsheet schema says bottomLeft. Excel shrugs. openpyxl 3.1.5 checks the value and refuses the whole workbook, and the error only says it couldn't read the worksheets. The loader now retries once after removing the <pane> and <selection> elements, which only control how the sheet is displayed, and tells the caller it did. (The filled copy loses its frozen header row, which I can live with.) This file also writes <pane ...></pane> instead of a self-closing tag, so you have to remove the closing tag too or you've made the sheet worse.
It couldn't see the right sheet. The sheet that opens first is an instructions page; the one you fill in is somewhere among the workbook's seven sheets, and some sheets are hidden. So the inspect tool now lists every sheet, hidden ones included, and the agent picks.
It couldn't see where the data goes. The header is six rows deep: machine column codes, the column names people read, mandatory markers, help text, format hints, an example product. Data starts at row 7. The inspect tool now returns those top rows as they are, and fill takes an explicit header row and first data row, so the agent can say exactly where to write.
Notice that none of these fixes taught a tool about a marketplace; they all gave the agent more to look at. Here's roughly what the inspect tool returns now, abridged, for a small made-up template:
{
"sheets": [
{"name": "Guide", "visible": true, "max_row": 40, "max_column": 2},
{"name": "Upload", "visible": true, "max_row": 6, "max_column": 3},
{"name": "Lists", "visible": false, "max_row": 300, "max_column": 5}
],
"sheet_name": "Upload",
"preview": [
{"row": 1, "hidden": true, "cells": ["ps_product_name", "ps_price", "ps_stock"]},
{"row": 2, "hidden": false, "cells": ["Product Name", "Price", "Stock"]},
{"row": 3, "hidden": false, "cells": ["Mandatory", "Mandatory", "Optional"]},
...
{"row": 6, "hidden": false, "cells": ["Example: canvas sneaker", "199.00", "20"]}
],
"preview_truncated": false,
"suggested_data_start_row": 7
}
The last field is the one suggestion in there, and the next section is why it exists.
The warning the agent read past
With the tools fixed, the whole workflow passed on synthetic templates. On the real one, the agent picked row 5 as the first data row. The product rows went over the last two rows of the header, and two of the product's five size variants landed in rows the marketplace skips on upload. Nothing failed. The fill tool had even returned overwrote N non-empty cell(s), and the agent saved the file anyway (saving is its own tool call, which is what makes the file the run's output). The synthetic templates had short headers, so the agent had never had to work out where data starts, and nothing had tested whether it could.
Why is an overwrite only a warning, and not something fill refuses? Because fill doesn't know the template. It can see that a cell had content; it can't tell a header row from an example row the seller expects to be replaced, and teaching it the difference per marketplace is exactly what the rule forbids. So the tool reports, and the agent decides. That puts the burden on the agent, and two things changed to carry it. The inspect tool now returns suggested_data_start_row: one past the last non-empty row in the first 200. An empty upload template is mostly header, so that's usually right, and it's labelled a suggestion for the agent to check. And the prompt now spells out what the agent should do with it and with the warning. These are the lines, quoted from the prompt with the tool-call syntax trimmed:
data_start_rowis NOTheader_row + 1: it is the first row AFTER the whole band. Cross-check against any example sheet's first data row; they must agree.A warning of the form
overwrote N non-empty cell(s) at/after row <X>means yourdata_start_rowwas TOO LOW. RE-CALLfill_xlsx_templatewith a higherdata_start_rowBEFORE you store; never persist a fill that overwrote band rows.
The rerun on the same real template put the first product in row 7, left all six header rows alone, and passed every automated check. It never hit the overwrite warning, so that instruction hasn't been exercised by a real run; the suggestion was enough on its own. The end-to-end tests can now upload real templates at test time. The files stay out of the repository, since they belong to the marketplace.
The probe below reproduces the unreadable file and the header problem with a template it generates itself (Python 3.11, openpyxl 3.1.5). The last line is the row-5 mistake in miniature: counting from the column-name row gives row 3, the suggestion gives row 7.
openpyxl 3.1.5
strict load: ValueError | Unable to read workbook: could not read worksheets from None.
caused by: ValueError | Value must be one of {'topLeft', 'bottomLeft', 'bottomRight', 'topRight'}
tolerant load: ok, rows = 6
header_row + 1 = 3 | suggested_data_start_row = 7
The probe
import io, re, zipfile
import openpyxl
from openpyxl import Workbook, load_workbook
# A template like a marketplace's: 6-row header band, data belongs at row 7.
wb = Workbook(); ws = wb.active; ws.title = "Upload"
band = [["ps_product_name", "ps_price", "ps_stock"], # machine codes
["Product Name", "Price", "Stock"], # human names
["Mandatory", "Mandatory", "Optional"], # markers
["Max 120 characters", "Number only", None], # help text
[None, "e.g. 199.00", None], # format hint
["Example: canvas sneaker", "199.00", "20"]] # example row
for row in band:
ws.append(row)
ws.freeze_panes = "A7"
buf = io.BytesIO(); wb.save(buf)
# Re-write the view XML the way some exporters do: a non-standard enum, non-self-closing.
def patch(data):
out = io.BytesIO()
with zipfile.ZipFile(io.BytesIO(data)) as src, zipfile.ZipFile(out, "w") as dst:
for info in src.infolist():
payload = src.read(info.filename)
if info.filename.startswith("xl/worksheets/"):
payload = re.sub(rb"<pane[^>]*/>",
b'<pane ySplit="6" topLeftCell="A7" activePane="bottom_left" state="frozen"></pane>', payload)
dst.writestr(info, payload)
return out.getvalue()
real = patch(buf.getvalue())
print("openpyxl", openpyxl.__version__)
try:
load_workbook(io.BytesIO(real))
except Exception as e:
print("strict load:", type(e).__name__, "|", str(e).splitlines()[0])
print(" caused by:", type(e.__context__).__name__, "|", str(e.__context__)[:90])
def strip_view(data): # drop <pane>/<selection>: view-only state
out = io.BytesIO()
with zipfile.ZipFile(io.BytesIO(data)) as src, zipfile.ZipFile(out, "w") as dst:
for info in src.infolist():
payload = src.read(info.filename)
if info.filename.startswith("xl/worksheets/"):
for tag in (rb"pane", rb"selection"):
payload = re.sub(rb"<" + tag + rb"\b[^>]*?(?:/>|>.*?</" + tag + rb">)", b"", payload, flags=re.S)
dst.writestr(info, payload)
return out.getvalue()
sheet = load_workbook(io.BytesIO(strip_view(real)))["Upload"]
print("tolerant load: ok, rows =", sheet.max_row)
def suggested_data_start_row(sheet, cap=200):
last = 0
for r in range(1, min(sheet.max_row, cap) + 1):
if any(str(c.value).strip() for c in sheet[r] if c.value is not None):
last = r
return last + 1 if last else None
header_row = 2 # the row with the column names people read
print("header_row + 1 =", header_row + 1, "| suggested_data_start_row =", suggested_data_start_row(sheet))
(The set in the caused by line prints in a different order each run.)
When I'd still write a dedicated tool
The seven tools were deleted, not kept as a fallback, so there's one path to test. The costs of the new path are real, and they're the ones this post walked through: the agent can put data in the wrong place without anything failing, the safeguard for that lives in prose in a prompt, and the frozen header is gone from files that needed the loader's retry. I'd still write a dedicated serializer for a format that's mine to define and where a wrong column is expensive. These formats belong to the marketplaces, and sellers bring their own copies, so a mapping I keep by hand was always going to be one step behind.

