ai

The CSV importer that proposes and never executes

The CSV importer that proposes and never executes

Every club that signs up already has members, and the members already live somewhere: a previous system's export, a payment provider's CSV, a spreadsheet someone has been maintaining by hand since 2019. The columns are called whatever that system called them. Vorname. Member Since. Plan (monthly). Tel.. Our import wants name, email, phone, planName, startDate, and for years the way you got from one to the other was a person with two browser tabs open, mapping headers by hand.

We put a model in front of that step in January. The design decision I want to write down is not that we did — everyone is doing that — but exactly where the model's authority ends, because we drew the line closer in than most and I think the line is the product.

Three endpoints

The import is three HTTP calls, and the model is only in the first one.

/v1/import/ai-analyze takes the uploaded file and returns a proposal. It requires a valid session and nothing else — no import permission, no organisation lookup — because it never touches organisation data. All it reads is the file you just gave it.

/v1/import/:entity/preview takes the file and a mapping, runs the real import code in validation-only mode, and returns what would be created, with every row's errors. This one requires the import permission, because it reads your organisation to check the rows against it.

/v1/import/:entity/execute takes the same inputs and writes. Import permission again, plus a capacity check — a club on a plan with a member limit cannot import its way past it.

The model's output is a suggestion for the second call's input. That is the entire integration. The code that writes to PostgreSQL takes a mapping as a plain object and has no idea whether a human or a model produced it, and it validates every row either way.

What the model actually sees

Not the file. The analyser parses the CSV, takes the headers and the first five rows, truncates any value over fifty characters, and renders that as text:

Column Headers (7 columns):
  1. "Vorname"
  2. "Nachname"
  3. "E-Mail"
  ...
Sample Data (first 5 rows):
Row 1:
  Vorname: Anna
  Nachname: Weber
  E-Mail: anna.w@...

Five rows is enough to tell a date column from a price column and a name from an email; five thousand would be slower, dearer, and no more accurate. The prompt then asks a narrow question — which of our eight entity types is this, and how do these headers map onto that entity's fields — and demands JSON back:

{
  "entityType": "contacts",
  "fieldMapping": { "Vorname": "name", "Nachname": "name", "E-Mail": "email" },
  "confidence": 0.92,
  "reasoning": "Member profile columns present alongside plan columns; chained import."
}

The field names the model may use are not in the prompt as prose. They are generated at request time from the same template definitions that drive the manual import and the downloadable CSV templates, so there is one list of valid fields and the model is handed it rather than remembering it. The temperature is 0.3, because this is classification and we want the same file to get the same answer on Tuesday as on Monday.

Then the code takes over. The entityType has to be one of the eight we know, or the response is rejected and the user is sent to the manual flow. Two columns both mapped to name is legal — the importer knows to combine first and last names — because that rule lives in the importer, not the model. Custom-field columns are stripped from a contacts mapping, because the model kept helpfully mapping them to tags and notes. The sample rows are run through the proposed mapping and shown to the user before preview, so the first thing they see is their own data in our columns, and a wrong guess is obvious in seconds.

The rule that needed a rule

The one genuinely hard call is a file that has both people and money in it: a name, an email, a plan, and a column of amounts with dates. Is that a contacts import or a transactions import? Both, really — and early on the model would pick one, and pick differently on similar files.

So the prompt now carries a stated rule: if there are member-profile columns and payment columns, it is contacts, and the importer chains the membership and transaction creation behind it; if there is only a contact identifier plus amounts and dates, it is transactions. Writing that down was more useful than any amount of prompt polishing, and it is the general lesson — where the model was inconsistent, the inconsistency was usually ours. We had not decided.

Why the line is where it is

It would be a small step to let the analyser call preview itself, and another small step to let it call execute when the preview comes back clean. Then the club uploads a file and members appear. It demos beautifully. We have not done it and I do not expect us to, for reasons that have nothing to do with capability.

The import writes memberships with end dates and payment records into a customer's system of record. Two bugs I fixed this spring make the point: archived contacts surfacing in searches they should never have matched, and non-renewing memberships — imported ones included — saved with an empty end date. Neither was a mapping error. Both were bugs in our code, the kind a person notices in a preview because the dates look wrong, and the kind a pipeline that executes on a clean preview would have run through at full speed. An import is much easier to review than to reverse.

So the model proposes, the preview shows, and a person with the right permission clicks execute. Everything the model contributes is inspectable in the response before it has any effect, and everything that has an effect is code we can test. That is a bounded workflow, not an autonomous one, and the difference is not how clever the model is. It is where you put the permissions.

Deyan Peev

Written by

Deyan Peev

Founding Engineer · Sofia, Bulgaria

Deyan Peev

Founding Engineer in Sofia, Bulgaria. Currently at 1club.

Elsewhere

© 2026 Deyan Peev