Day 157: One Tool Instead of Twenty Exporters
An agent could read your invoices out of Odoo, pull a list out of the knowledge base, or work through a mailbox, and then it had nowhere to put the result except into the chat window as text. Two days ago the delivery half landed: a plugin returns a native file block, Pinchy records a per-user grant for it, audits the delivery, and the file appears as a downloadable chip next to the agent’s reply (PR #874). Today the production half rides on it.
The exporter you do not write nine times
PR #893 adds one tool, pinchy_generate_file, that takes columns and rows an agent already has in hand and renders them to CSV, XLSX or PDF. “Export these records to CSV.” “Make me a PDF report.” “Give me a spreadsheet of the ledger.” It does not matter whether the data came from Odoo, from email, or from something the agent assembled itself, because the tool never learns where rows come from.
The alternative was the obvious one, and I want to write down why I did not take it: an export function per integration. That is perfectly sensible at two integrations. At nine it is nine places to fix a quoting bug, nine sets of tests, and nine slightly different opinions about what a date looks like.
The parts that took the actual time
Rendering a table is not hard. The list of ways a rendered table hurts someone is longer than the renderer.
A CSV cell whose text starts with =, +, -, @, a tab or a carriage return is not text when a spreadsheet opens it. It is a formula, and it executes on open, which is how a harmless-looking export turns into code running on somebody else’s machine. Those cells get an apostrophe prefix, and numbers stay numbers so nothing breaks for the ordinary case. The file also carries a UTF-8 BOM, RFC 4180 quoting and CRLF endings, which is the combination that makes it open correctly in the spreadsheet program people actually have.
The XLSX path preserves numeric and boolean cells rather than stringifying everything, and sanitizes the sheet name, because Excel rejects several characters that are perfectly legal in a title an agent would pick. The PDF path bounds every header and cell to a single line, so a long label cannot overlap its neighbor or run off the page. Neither is clever. Both are the difference between a file you can hand to a colleague and one you have to apologize for.
There are two ceilings, fifty thousand rows in and ten megabytes out, checked before anything is written. And there is an asymmetry I like: XLSX is now servable as a delivered file and deliberately not added to the upload allowlist, because Pinchy still cannot extract text from a spreadsheet you upload. Being able to hand you a format is not the same as being able to read it, and the two lists stay separate so the second capability does not get silently implied by the first.
Yesterday’s lesson, applied one day later
The tool needs the same permission posture as pinchy_write: it exists only for an agent that has been granted a writable workbench path, because generating a file is writing a file, and there is no reason for that to become a second, quieter way to get write access.
Then the audit rule, which is the part that made me happy about the timing. Every error path in this tool returns a details object with at least one non-sensitive field, so the route always suppresses the raw parameters. The parameters here are the columns and the rows: your customer list, your ledger, whatever the agent was asked to export. That is precisely the leak I spent yesterday closing in the email tools. One day later the same trap was waiting in a new tool, and this time it was closed before the tool ever ran in anger. Two independent reviews of this PR still caught one more instance of it, on an error path I had missed.
Day 157
The satisfying thing about a generic primitive is that it makes the security work worth doing once. There is exactly one place in Pinchy that decides how a cell becomes CSV, so there is exactly one place where formula injection is either handled or not. Had I written the per-integration version, that decision would live in nine files, and I can tell you with some confidence how many of those nine would have gotten it right on the first try, because a week ago I could not keep one email tool from logging a message body. Fewer places to be wrong is not an aesthetic preference. It is the only version of thoroughness I can actually sustain.