# Bulk agent import (CSV)

> Upload a CSV of agents, preview the diff against your existing roster, and commit inserts and updates row by row.

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.

| Column               | Required             | Notes                                                                 |
| -------------------- | ------------------- | --------------------------------------------------------------------- |
| `kind`               | Yes                 | `human` or `llm`. Determines which other columns apply.               |
| `external_agent_id`  | Yes                 | Your own identifier for the agent. This is the match key.             |
| `display_name`       | Yes                 | Shown in the roster and on the agent's avatar.                        |
| `email`              | No                  |                                                                       |
| `staff_id`           | No                  |                                                                       |
| `position`           | No                  |                                                                       |
| `employee_type`      | No                  | One of `fte`, `contractor`, `intern`, `temp`, `other`.                 |
| `site_code`          | No                  | Resolved to a site id by code.                                        |
| `hotline_code`       | No                  | Resolved to the agent's primary hotline id by code.                   |
| `tl_email`           | No                  | Resolved to a team leader id by email.                                |
| `tm_email`           | No                  | Resolved to a team manager id by email.                               |
| `agent_config_id`    | For `llm` rows      | The agent config the LLM agent runs.                                  |
| `default_provider`   | No (`llm` only)     |                                                                       |
| `default_model`      | No (`llm` only)     |                                                                       |
| `hourly_cost_paise`  | No (`llm` only)     | Integer paise, not rupees. `150000` means ₹1500.00 per hour.           |

`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:

| Situation                                              | Diff        |
| ------------------------------------------------------ | ----------- |
| `external_agent_id` is not in the existing roster      | `+ INSERT`  |
| It matches an existing agent, and at least one field differs | `~ UPDATE`  |
| It matches an existing agent, and nothing differs      | `= no-op`   |
| The row failed validation or reference resolution      | `⚠ ERROR`   |

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.

> **WARNING:**
> 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.

| CSV column     | Resolved against       | Matching                          |
| -------------- | ---------------------- | --------------------------------- |
| `site_code`    | Site `code`            | Case-insensitive (upper-cased)    |
| `hotline_code` | Hotline `code`         | Case-insensitive (upper-cased)    |
| `tl_email`     | Team leader `email`    | Case-insensitive (lower-cased)    |
| `tm_email`     | Team manager `email`   | Case-insensitive (lower-cased)    |

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`.

> **WARNING:**
> **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

| Message pattern                               | Cause                                                                 | Fix                                                                       |
| --------------------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| `CSV missing required columns: …`             | The header row lacks `kind`, `external_agent_id` or `display_name`.   | Add the columns, or start from the downloaded template. Nothing is parsed until this passes. |
| A parse error from the CSV reader              | Malformed quoting, a ragged row, or an unreadable file.               | Check the reported row; quote any cell containing a comma, quote or newline, and double internal quotes. |
| Invalid `kind`                                 | The cell is neither `human` nor `llm`.                                | Use one of the two exact values.                                          |
| Missing `external_agent_id` or `display_name`  | The column exists but the cell is blank on that row.                  | Fill the cell. The preview shows *no id* / *no name* on such rows.        |
| Invalid `employee_type`                        | Not one of `fte`, `contractor`, `intern`, `temp`, `other`.            | Use one of the listed values, or leave the cell empty.                    |
| Unknown `site_code` / `hotline_code`           | No site or hotline in this org has that code.                         | Correct the code, or create the site/hotline first — the preview re-resolves on its own. |
| Unknown `tl_email` / `tm_email`                | No team leader/manager in this org has that email.                    | Correct the address, or add the email to that person's record.            |
| Missing `agent_config_id` on an `llm` row      | LLM agents need a config to run.                                      | Supply the config id, or change `kind` to `human`.                        |
| Invalid `hourly_cost_paise`                    | Not an integer, e.g. `1500.00` or `₹1500`.                            | Write integer paise with no separators or symbols: `150000`.              |
| `Import failed: …` on a row after commit       | The server rejected that individual mutation.                         | Read the message in the row's cell, fix the data, re-upload the file.     |

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.

## Related

- [Authentication](/concepts/authentication) — the API key and scopes the
  admin procedures authenticate with
