The bulk import screen turns a CSV file into agent records. You pick a file, the screen parses it in your browser, resolves every reference against your org’s sites, hotlines, team leaders, team managers and existing agents, and shows you a per-row diff. Nothing is written until you press Commit. The flow has three stages:
  1. Parse. The file is read client-side. Headers are trimmed, lowercased, and internal whitespace is replaced with underscores, so External Agent ID and external_agent_id are the same column. Empty lines are skipped.
  2. Preview. Each row is classified as + INSERT, ~ UPDATE, = no-op, or ⚠ ERROR, with the specific fields that would change.
  3. Commit. Rows marked INSERT and UPDATE are sent to the control plane one at a time. No-op and error rows are skipped.

Column reference and required fields

These are the columns the import template lays out, in order. A column you leave out of the header row is simply not imported. kind, external_agent_id and display_name are the three columns the parser checks for before it will accept the file at all. If any of them is absent from the header row, the upload is rejected with CSV missing required columns: … and no preview is produced. Fix the header and re-drop the file. The Download template action emits exactly these columns plus one sample row, which is the fastest way to get the header spelling and the code/email conventions right.

Human vs LLM columns

kind decides which half of the sheet matters:
  • human rows use the identity and org columns: email, staff_id, position, employee_type, site_code, hotline_code, tl_email, tm_email.
  • llm rows additionally require agent_config_id, and may carry default_provider, default_model and hourly_cost_paise.
On update, the LLM-specific fields (agent_config_id, default_provider, default_model, hourly_cost_paise) are only sent when the row’s kind is llm. Putting a model name on a human row has no effect on commit.

How rows are matched to existing agents

external_agent_id is the match key, and the only match key. The screen loads your existing roster and indexes it by external_agent_id, then for each CSV row: The comparison is exact on the string. It is not matched on email, name, or staff id — two rows with the same person’s email but different external_agent_id values produce two separate agents. This makes the import idempotent with respect to the key: re-uploading the same file after a successful commit yields all = no-op rows. Changing a cell and re-uploading yields an ~ UPDATE for just that agent, listing just that field.
Changing external_agent_id in your source system and re-importing does not rename an agent. The new value does not match anything, so the row is treated as an INSERT and you end up with a duplicate.

How references resolve (site and hotline codes, TL/TM emails)

The CSV carries human-readable references, not internal ids. The screen resolves them against your org before the preview renders. So hq, HQ and Hq all resolve to the same site, and Lead@Example.com resolves to the team leader stored as lead@example.com. A reference that does not resolve is a row error, not a silent null. That is deliberate — a typo’d site code would otherwise import an agent with no site and no indication that anything went wrong. Team leaders and managers must already have an email on record to be targetable by tl_email / tm_email. Records without an email are not in the lookup index and cannot be matched by that column. Because references resolve against live data, creating the missing site, hotline or team leader in the admin screens and coming back re-resolves the rows you already loaded — the preview recomputes when the lookup data updates, without you re-picking the file.

Reading the diff preview

Once a file parses, the drop zone is replaced by the preview table, with counters across the top for Insert, Update, No change and Error. Each row shows:
  • Diff — the classification chip described above.
  • Kind — human or llm, as parsed from the row.
  • Agent — display_name on top, external_agent_id beneath it. Missing values render as no name / no id.
  • Email — the parsed email, or —.
  • Changes / errors — for an UPDATE, one chip per field that would change; hovering a chip shows field: before → after. Only the first five chips are rendered, with a +N more counter after them. For an ERROR row, this cell lists the validation messages instead.
An INSERT row shows no change chips; the whole record is new. A = no-op row shows —. The header button reads Commit N rows, where N is inserts plus updates. If that number is zero, the button is disabled — there is nothing to write.

What commit does, and what happens when a row fails

Commit asks for confirmation first. The dialog summarises the work: N INSERT · N UPDATE · N no-change. If there are error rows, the dialog says so instead and offers to import the remainder, skipping the error rows. Then, for each INSERT or UPDATE row in file order, one request at a time:
  • INSERT rows call adminAgents.create with orgId, kind, external_agent_id, display_name, and a patch carrying every other parsed column.
  • UPDATE rows call adminAgents.update with orgId, the matched agentId, and a patch of the identity and org fields — plus the LLM fields when the row’s kind is llm.
The commit is not a transaction. Rows are committed independently. If row 40 of 100 fails, rows 1–39 are already written and rows 41–100 still proceed. There is no rollback and no all-or-nothing mode.
A progress readout appears in the toolbar while the commit runs — done / total, plus a failed count once anything has failed — alongside a progress bar. The Commit button is disabled for the duration. When a row’s mutation throws, that row flips to ⚠ ERROR in the table and the server’s message is appended to its Changes / errors cell as Import failed: …. That is where to look for the reason a specific agent did not import; the closing toast is only a pointer (N of M rows failed — see the Changes / errors column.). On a clean run you get Imported N rows instead. After the run the roster list is refreshed, so the agents you just created or changed appear immediately elsewhere in the admin screens. Recovering from a partial commit is safe: fix the offending cells in your CSV and re-upload the whole file. Rows that already landed come back as = no-op or as an ~ UPDATE limited to the fields you edited, because matching is by external_agent_id. Discard clears the file, the parsed rows and the progress counters. It does not undo anything already committed.

Validation errors and how to fix them

Error rows never reach the control plane. They are excluded from the commit set, so the counters at the top of the preview tell you exactly how much of your file will be written.
  • Authentication — the API key and scopes the admin procedures authenticate with